[Resolver] Screenshot the nodes of the test plugin. (#81405) (#82488)

This PR adds screenshot comparison tests for the nodes in the graph on the test plugin.
Run the tests using this command:
`yarn test:ftr --config x-pack/test/plugin_functional/config.ts --grep Resolver`
This commit is contained in:
Robert Austin 2020-11-03 17:00:51 -05:00 committed by GitHub
parent 90cf81fda6
commit db0e1052f0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 243 additions and 68 deletions

10
.gitignore vendored
View file

@ -18,11 +18,21 @@ target
.idea
*.iml
*.log
# Ignore certain functional test runner artifacts
/test/*/failure_debug
/test/*/screenshots/diff
/test/*/screenshots/failure
/test/*/screenshots/session
/test/*/screenshots/visual_regression_gallery.html
# Ignore the same artifacts in x-pack
/x-pack/test/*/failure_debug
/x-pack/test/*/screenshots/diff
/x-pack/test/*/screenshots/failure
/x-pack/test/*/screenshots/session
/x-pack/test/*/screenshots/visual_regression_gallery.html
/html_docs
.eslintcache
/plugins/

View file

@ -61,6 +61,8 @@ export async function ScreenshotsProvider({ getService }: FtrProviderContext) {
if (updateBaselines) {
log.debug('Updating baseline snapshot');
// Make the directory if it doesn't exist
await mkdirAsync(dirname(baselinePath), { recursive: true });
await writeFileAsync(baselinePath, readFileSync(sessionPath));
return 0;
} else {

View file

@ -215,7 +215,8 @@ export function mockTreeWithNoAncestorsAnd2Children({
const secondChild: SafeResolverEvent = mockEndpointEvent({
pid: 2,
entityID: secondChildID,
processName: 'e',
processName:
'really_really_really_really_really_really_really_really_really_really_really_really_really_really_long_node_name',
parentEntityID: originID,
timestamp: 1600863932318,
});
@ -388,5 +389,31 @@ export function mockTreeWithNoAncestorsAndTwoChildrenAndRelatedEventsOnOrigin({
eventCategory: 'registry',
}),
];
// Add one additional event for each category
const categories: string[] = [
'authentication',
'database',
'driver',
'file',
'host',
'iam',
'intrusion_detection',
'malware',
'network',
'package',
'process',
'web',
];
for (const category of categories) {
relatedEvents.push(
mockEndpointEvent({
entityID: originID,
parentEntityID,
eventID: `${relatedEvents.length}`,
eventType: 'access',
eventCategory: category,
})
);
}
return withRelatedEventsOnOrigin(baseTree, relatedEvents);
}

View file

@ -208,7 +208,7 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children',
});
});
describe('Resolver, when analyzing a tree that has two related events for the origin', () => {
describe('Resolver, when analyzing a tree that has 2 related registry and 1 related event of all other categories for the origin node', () => {
beforeEach(async () => {
// create a mock data access layer with related events
const {
@ -282,7 +282,21 @@ describe('Resolver, when analyzing a tree that has two related events for the or
simulator.map(() =>
simulator.testSubject('resolver:map:node-submenu-item').map((node) => node.text())
)
).toYieldEqualTo(['2 registry']);
).toYieldEqualTo([
'1 authentication',
'1 database',
'1 driver',
'1 file',
'1 host',
'1 iam',
'1 intrusion_detection',
'1 malware',
'1 network',
'1 package',
'1 process',
'2 registry',
'1 web',
]);
});
});
});

View file

@ -13,7 +13,7 @@ import { urlSearch } from '../test_utilities/url_search';
// 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 with no ancestors and two children and two related registry event on the origin, and when the component instance ID is ${resolverComponentInstanceID}`, () => {
describe(`Resolver: when analyzing a tree with no ancestors and two children and 2 related registry events and 1 event of each other category on the origin, and when the component instance ID is ${resolverComponentInstanceID}`, () => {
/**
* Get (or lazily create and get) the simulator.
*/
@ -272,22 +272,33 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and
await expect(
simulator().map(() => {
// The link text is split across two columns. The first column is the count and the second column has the type.
const typesAndCounts: Array<{ type: string; link: string }> = [];
const type = simulator().testSubject('resolver:panel:node-events:event-type-count');
const link = simulator().testSubject('resolver:panel:node-events:event-type-link');
return {
typeLength: type.length,
linkLength: link.length,
typeText: type.text(),
linkText: link.text(),
};
for (let index = 0; index < type.length; index++) {
typesAndCounts.push({
type: type.at(index).text(),
link: link.at(index).text(),
});
}
return typesAndCounts;
})
).toYieldEqualTo({
typeLength: 1,
linkLength: 1,
linkText: 'registry',
// EUI's Table adds the column name to the value.
typeText: 'Count2',
});
).toYieldEqualTo([
// Because there is no printed whitespace after "Count", the count immediately follows it.
{ link: 'registry', type: 'Count2' },
{ link: 'authentication', type: 'Count1' },
{ link: 'database', type: 'Count1' },
{ link: 'driver', type: 'Count1' },
{ link: 'file', type: 'Count1' },
{ link: 'host', type: 'Count1' },
{ link: 'iam', type: 'Count1' },
{ link: 'intrusion_detection', type: 'Count1' },
{ link: 'malware', type: 'Count1' },
{ link: 'network', type: 'Count1' },
{ link: 'package', type: 'Count1' },
{ link: 'process', type: 'Count1' },
{ link: 'web', type: 'Count1' },
]);
});
describe('and when the user clicks the registry events link', () => {
beforeEach(async () => {
@ -377,7 +388,11 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and
.testSubject('resolver:node-list:node-link:title')
.map((node) => node.text());
})
).toYieldEqualTo(['c.ext', 'd', 'e']);
).toYieldEqualTo([
'c.ext',
'd',
'really_really_really_really_really_really_really_really_really_really_really_really_really_really_long_node_name',
]);
});
});
});

View file

@ -12,41 +12,15 @@ import { ResolverNodeStats } from '../../../common/endpoint/types';
import { useRelatedEventByCategoryNavigation } from './use_related_event_by_category_navigation';
import { useColors } from './use_colors';
/**
* i18n-translated titles for submenus and identifiers for display of states:
* initialMenuStatus: submenu before it has been opened / requested data
* menuError: if the submenu requested data, but received an error
*/
export const subMenuAssets = {
initialMenuStatus: i18n.translate(
'xpack.securitySolution.endpoint.resolver.relatedNotRetrieved',
{
defaultMessage: 'Related Events have not yet been retrieved.',
}
),
menuError: i18n.translate('xpack.securitySolution.endpoint.resolver.relatedRetrievalError', {
defaultMessage: 'There was an error retrieving related events.',
}),
relatedEvents: {
title: i18n.translate('xpack.securitySolution.endpoint.resolver.relatedEvents', {
defaultMessage: 'Events',
}),
},
};
interface ResolverSubmenuOption {
optionTitle: string;
action: () => unknown;
prefix?: number | JSX.Element;
}
/**
* Until browser support accomodates the `notation="compact"` feature of Intl.NumberFormat...
* exported for testing
* @param num The number to format
* @returns [mantissa ("12" in "12k+"), Scalar of compact notation (k,M,B,T), remainder indicator ("+" in "12k+")]
*/
export function compactNotationParts(num: number): [number, string, string] {
export function compactNotationParts(
num: number
): [mantissa: number, compactNotation: string, remainderIndicator: string] {
if (!Number.isFinite(num)) {
return [num, '', ''];
}
@ -85,8 +59,6 @@ export function compactNotationParts(num: number): [number, string, string] {
return [Math.floor(num / scale), prefix, (num / scale) % 1 > Number.EPSILON ? hasRemainder : ''];
}
export type ResolverSubmenuOptionList = ResolverSubmenuOption[] | string;
/**
* A Submenu that displays a collection of "pills" for each related event
* category it has events for.

View file

@ -17673,10 +17673,7 @@
"xpack.securitySolution.endpoint.resolver.processDescription": "{isEventBeingAnalyzed, select, true {分析されたイベント· {descriptionText}} false {{descriptionText}}}",
"xpack.securitySolution.endpoint.resolver.relatedEventLimitExceeded": "{numberOfEventsMissing} {category}件のイベントを表示できませんでした。データの上限に達しました。",
"xpack.securitySolution.endpoint.resolver.relatedEventLimitTitle": "このリストには、{numberOfEntries}件のプロセスイベントが含まれています。",
"xpack.securitySolution.endpoint.resolver.relatedEvents": "イベント",
"xpack.securitySolution.endpoint.resolver.relatedLimitsExceededTitle": "このリストには、{numberOfEventsDisplayed} {category}件のイベントが含まれます。",
"xpack.securitySolution.endpoint.resolver.relatedNotRetrieved": "関連するイベントがまだ取得されていません。",
"xpack.securitySolution.endpoint.resolver.relatedRetrievalError": "関連するイベントの取得中にエラーが発生しました。",
"xpack.securitySolution.endpoint.resolver.runningProcess": "プロセスの実行中",
"xpack.securitySolution.endpoint.resolver.runningTrigger": "トリガーの実行中",
"xpack.securitySolution.endpoint.resolver.terminatedProcess": "プロセスを中断しました",

View file

@ -17692,10 +17692,7 @@
"xpack.securitySolution.endpoint.resolver.processDescription": "{isEventBeingAnalyzed, select, true {已分析的事件 · {descriptionText}} false {{descriptionText}}}",
"xpack.securitySolution.endpoint.resolver.relatedEventLimitExceeded": "{numberOfEventsMissing} 个{category}事件无法显示,因为已达到数据限制。",
"xpack.securitySolution.endpoint.resolver.relatedEventLimitTitle": "此列表包括 {numberOfEntries} 个进程事件。",
"xpack.securitySolution.endpoint.resolver.relatedEvents": "事件",
"xpack.securitySolution.endpoint.resolver.relatedLimitsExceededTitle": "此列表包括 {numberOfEventsDisplayed} 个{category}事件。",
"xpack.securitySolution.endpoint.resolver.relatedNotRetrieved": "尚未检索相关事件。",
"xpack.securitySolution.endpoint.resolver.relatedRetrievalError": "检索相关事件时出现错误。",
"xpack.securitySolution.endpoint.resolver.runningProcess": "正在运行的进程",
"xpack.securitySolution.endpoint.resolver.runningTrigger": "正在运行的触发器",
"xpack.securitySolution.endpoint.resolver.terminatedProcess": "已终止进程",

View file

@ -5,7 +5,6 @@
*/
import { resolve } from 'path';
import fs from 'fs';
import { KIBANA_ROOT } from '@kbn/test';
import { FtrConfigProviderContext } from '@kbn/test/types/ftr';
import { services } from './services';
import { pageObjects } from './page_objects';
@ -14,9 +13,7 @@ import { pageObjects } from './page_objects';
// that returns an object with the projects config values
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
const xpackFunctionalConfig = await readConfigFile(
require.resolve('../security_solution_endpoint/config.ts')
);
const xpackFunctionalConfig = await readConfigFile(require.resolve('../functional/config.js'));
// Find all folders in ./plugins since we treat all them as plugin folder
const allFiles = fs.readdirSync(resolve(__dirname, 'plugins'));
@ -43,12 +40,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
serverArgs: [
...xpackFunctionalConfig.get('kbnTestServer.serverArgs'),
...plugins.map((pluginDir) => `--plugin-path=${resolve(__dirname, 'plugins', pluginDir)}`),
`--plugin-path=${resolve(
KIBANA_ROOT,
'test/plugin_functional/plugins/core_provider_plugin'
)}`,
// Required to load new platform plugins via `--plugin-path` flag.
'--env.name=development',
],
},
uiSettings: xpackFunctionalConfig.get('uiSettings'),

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -4,24 +4,174 @@
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { WebElementWrapper } from '../../../../../test/functional/services/lib/web_element_wrapper';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getPageObjects, getService }: FtrProviderContext) {
const expectedDifference = 0.09;
export default function ({
getPageObjects,
getService,
updateBaselines,
}: FtrProviderContext & { updateBaselines: boolean }) {
const pageObjects = getPageObjects(['common']);
const testSubjects = getService('testSubjects');
const screenshot = getService('screenshots');
const find = getService('find');
const browser = getService('browser');
describe('Resolver test app', function () {
this.tags('ciGroup7');
beforeEach(async function () {
// Note: these tests are intended to run on the same page in serial.
before(async function () {
await pageObjects.common.navigateToApp('resolverTest');
// make the window big enough that all nodes are fully in view (for screenshots)
await browser.setScreenshotSize(3840, 2400);
});
it('renders at least one node, one node-list, one edge line, and graph controls', async function () {
it('renders at least one node', async () => {
await testSubjects.existOrFail('resolver:node');
});
it('renders a node list', async () => {
await testSubjects.existOrFail('resolver:node-list');
});
it('renders at least one edge line', async () => {
await testSubjects.existOrFail('resolver:graph:edgeline');
});
it('renders graph controls', async () => {
await testSubjects.existOrFail('resolver:graph-controls');
});
/**
* The mock data used to render the Resolver test plugin has 3 nodes:
* - an origin node with 13 related event pills
* - a non-origin node with a long name
* - a non-origin node with a short name
*
* Each node is captured when selected and unselected.
*
* For each node is captured (once when selected and once when unselected) in each of the following interaction states:
* - primary button hovered
* - pill is hovered
* - pill is clicked
* - pill is clicked and hovered
*/
// Because the lint rules will not allow files that include upper case characters, we specify explicit file name prefixes
const nodeDefinitions: Array<[nodeID: string, fileNamePrefix: string, hasAPill: boolean]> = [
['origin', 'origin', true],
['firstChild', 'first_child', false],
['secondChild', 'second_child', false],
];
for (const [nodeID, fileNamePrefix, hasAPill] of nodeDefinitions) {
describe(`when the user is interacting with the node with ID: ${nodeID}`, () => {
let element: () => Promise<WebElementWrapper>;
beforeEach(async () => {
element = () => find.byCssSelector(`[data-test-resolver-node-id="${nodeID}"]`);
});
it('should render as expected', async () => {
expect(
await screenshot.compareAgainstBaseline(
`${fileNamePrefix}`,
updateBaselines,
await element()
)
).to.be.lessThan(expectedDifference);
});
describe('when the user hovers over the primary button', () => {
let button: WebElementWrapper;
beforeEach(async () => {
// hover the button
button = await (await element()).findByCssSelector(
`button[data-test-resolver-node-id="${nodeID}"]`
);
await button.moveMouseTo();
});
it('should render as expected', async () => {
expect(
await screenshot.compareAgainstBaseline(
`${fileNamePrefix}_with_primary_button_hovered`,
updateBaselines,
await element()
)
).to.be.lessThan(expectedDifference);
});
describe('when the user has clicked the primary button (which selects the node.)', () => {
beforeEach(async () => {
// select the node
await button.click();
});
it('should render as expected', async () => {
expect(
await screenshot.compareAgainstBaseline(
`${fileNamePrefix}_selected_with_primary_button_hovered`,
updateBaselines,
await element()
)
).to.be.lessThan(expectedDifference);
});
describe('when the user has moved their mouse off of the primary button (and onto the zoom controls.)', () => {
beforeEach(async () => {
// move the mouse away
const zoomIn = await testSubjects.find('resolver:graph-controls:zoom-in');
await zoomIn.moveMouseTo();
});
it('should render as expected', async () => {
expect(
await screenshot.compareAgainstBaseline(
`${fileNamePrefix}_selected`,
updateBaselines,
await element()
)
).to.be.lessThan(expectedDifference);
});
if (hasAPill) {
describe('when the user hovers over the first pill', () => {
let firstPill: () => Promise<WebElementWrapper>;
beforeEach(async () => {
firstPill = async () => {
// select a pill
const pills = await (await element()).findAllByTestSubject(
'resolver:map:node-submenu-item'
);
return pills[0];
};
// move mouse to first pill
await (await firstPill()).moveMouseTo();
});
it('should render as expected', async () => {
const diff = await screenshot.compareAgainstBaseline(
`${fileNamePrefix}_selected_with_first_pill_hovered`,
updateBaselines,
await element()
);
expect(diff).to.be.lessThan(expectedDifference);
});
describe('when the user clicks on the first pill', () => {
beforeEach(async () => {
// click the first pill
await (await firstPill()).click();
});
it('should render as expected', async () => {
expect(
await screenshot.compareAgainstBaseline(
`${fileNamePrefix}_selected_with_first_pill_selected`,
updateBaselines,
await element()
)
).to.be.lessThan(expectedDifference);
});
});
});
}
});
});
});
});
}
});
}