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`
10
.gitignore
vendored
|
@ -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/
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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": "プロセスを中断しました",
|
||||
|
|
|
@ -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": "已终止进程",
|
||||
|
|
|
@ -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'),
|
||||
|
|
After Width: | Height: | Size: 8.5 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 10 KiB |
BIN
x-pack/test/plugin_functional/screenshots/baseline/origin.png
Normal file
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 19 KiB |
After Width: | Height: | Size: 19 KiB |
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 15 KiB |
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|