diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2aa89e3..b0ccfbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Setup Node.js environment for build + - name: Setup Node.js environment for build and testing uses: actions/setup-node@v2.1.2 with: node-version: "18.x" @@ -40,13 +40,11 @@ jobs: - name: Install dependencies run: yarn install --frozen-lockfile - - name: Build and test frontend + - name: Build frontend run: yarn build - - name: Setup Node.js environment for E2E testing - uses: actions/setup-node@v2.1.2 - with: - node-version: "18.x" + - name: Test components + run: make test:component - name: Install E2E dependencies run: | diff --git a/.gitignore b/.gitignore index 3b97ee5..7444bcc 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,9 @@ playwright* test-results tests-examples +# webpack test junk +test/dist/* + # autogenerated by Makefile for e2e e2e/grafana-docker.json diff --git a/Makefile b/Makefile index 4266307..3b8ff3f 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,7 @@ NETWORK_NAME=esnet-networkmap-e2e-net CONTAINER_ID=$(shell docker ps -f name=$(CONTAINER_NAME) -q) INSTANCES=$(shell docker ps --filter name=$(CONTAINER_NAME) --filter name=$(PROXY_NAME) -qa) NETWORKS=$(shell docker network ls --filter name=${NETWORK_NAME} -q) +SPINUP_SLEEP_T=5 .PHONY: prod prod: @@ -43,22 +44,32 @@ compose: docker inspect $(CONTAINER_NAME) > $(PROJECT_DIR)/e2e/grafana-docker.json .PHONY: test test: compose + @echo "Starting component tests..." yarn test - sleep 2 + yarn test:react + @echo "Waiting for container to spin up..." + @sleep $(SPINUP_SLEEP_T) + @echo "Starting E2E Tests..." yarn e2e .PHONY: test\:component test\:component: + @echo "Starting component tests..." yarn test + yarn test:react .PHONY: test\:e2e test\:e2e: compose # run e2e tests + @echo "Waiting for container to spin up..." + @sleep $(SPINUP_SLEEP_T) + @echo "Starting E2E Tests..." yarn run e2e .PHONY: test\:ui test\:ui: compose # run e2e tests, but with ui + @echo "Starting E2E tests in Playwright UI..." yarn run e2e:ui .PHONY: testignore @@ -91,4 +102,4 @@ publish: check_version prod testignore confirm push clean: @rm -rf ${PROJECT_DIR}.config/env @if test "$(strip $(INSTANCES))" = ""; then echo "No instances to cleanup."; else docker rm -v -f $(INSTANCES); fi - @if test "$(strip $(NETWORKS))" = ""; then echo "No network $(NETWORK_NAME) found to remove."; else docker network rm $(NETWORKS); fi \ No newline at end of file + @if test "$(strip $(NETWORKS))" = ""; then echo "No network $(NETWORK_NAME) found to remove."; else docker network rm $(NETWORKS); fi diff --git a/README.md b/README.md index a0308c6..ffa098d 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ It also contains an example topology that will be used while setting up the Netw - Input your Google Sheets API key into the "API Key" input -- Click [Test + Save] +- Click [Test + Save] ### 2. Setup a Test Dashboard @@ -187,7 +187,7 @@ Leave this set to gray. It will help to show when we've correctly associated edg **Layer 1 Endpoint Identifier** -This input specifies the attribute of "endpoint_identifiers" prop of each of the "edges" objects in your JSON topology. Each "endpoint_identifiers" entry in the JSON should have a key matching the value specified in this text box (although the assigned JSON value is an array of node names of length two, does not matter). The value for Grafana here should be left as the default "pops". +This input specifies the attribute of "endpoint_identifiers" prop of each of the "edges" objects in your JSON topology. Each "endpoint_identifiers" entry in the JSON should have a key matching the value specified in this text box (although the assigned JSON value is an array of node names of length two, does not matter). The value for Grafana here should be left as the default "names". **Layer 1 Node Highlight Color** diff --git a/demonstration/dashboard.json b/demonstration/dashboard.json index 87432ee..6f8b1f6 100644 --- a/demonstration/dashboard.json +++ b/demonstration/dashboard.json @@ -97,10 +97,10 @@ "dstField": "dst", "dstFieldLabel": "To:", "edgeWidth": 3, - "endpointId": "pops", + "endpointId": "names", "inboundValueField": "in_bits", "legend": true, - "mapjson": "{\"edges\":[{\"name\":\"A--B\",\"meta\":{\"endpoint_identifiers\":{\"pops\":[\"A\",\"B\"]}},\"coordinates\":[[42.30619185993376,-106.00734642032997],[42.30619185993376,-92.30145333497663]],\"children\":[]},{\"name\":\"A--C\",\"meta\":{\"endpoint_identifiers\":{\"pops\":[\"A\",\"C\"]}},\"coordinates\":[[42.30619185993376,-106.00734642032997],[34.5952660320894,-99.10139879776413]],\"children\":[]},{\"name\":\"B--C\",\"meta\":{\"endpoint_identifiers\":{\"pops\":[\"B\",\"C\"]}},\"coordinates\":[[42.30619185993376,-92.30145333497663],[34.5952660320894,-99.10139879776413]],\"children\":[]}],\"nodes\":[{\"name\":\"A\",\"meta\":{\"display_name\":\"\",\"svg\":\"\",\"template\":\"\"},\"coordinate\":[42.30619185993376,-106.00734642032997]},{\"name\":\"B\",\"meta\":{\"display_name\":\"\",\"svg\":\"\",\"template\":\"\"},\"coordinate\":[42.30619185993376,-92.30145333497663]},{\"name\":\"C\",\"meta\":{\"display_name\":\"\",\"svg\":\"\",\"template\":\"\"},\"coordinate\":[34.5952660320894,-99.10139879776413]}]}", + "mapjson": "{\"edges\":[{\"name\":\"A--B\",\"meta\":{\"endpoint_identifiers\":{\"names\":[\"A\",\"B\"]}},\"coordinates\":[[42.30619185993376,-106.00734642032997],[42.30619185993376,-92.30145333497663]],\"children\":[]},{\"name\":\"A--C\",\"meta\":{\"endpoint_identifiers\":{\"names\":[\"A\",\"C\"]}},\"coordinates\":[[42.30619185993376,-106.00734642032997],[34.5952660320894,-99.10139879776413]],\"children\":[]},{\"name\":\"B--C\",\"meta\":{\"endpoint_identifiers\":{\"names\":[\"B\",\"C\"]}},\"coordinates\":[[42.30619185993376,-92.30145333497663],[34.5952660320894,-99.10139879776413]],\"children\":[]}],\"nodes\":[{\"name\":\"A\",\"meta\":{\"display_name\":\"\",\"svg\":\"\",\"template\":\"\"},\"coordinate\":[42.30619185993376,-106.00734642032997]},{\"name\":\"B\",\"meta\":{\"display_name\":\"\",\"svg\":\"\",\"template\":\"\"},\"coordinate\":[42.30619185993376,-92.30145333497663]},{\"name\":\"C\",\"meta\":{\"display_name\":\"\",\"svg\":\"\",\"template\":\"\"},\"coordinate\":[34.5952660320894,-99.10139879776413]}]}", "name": "Layer 1", "nodeHighlight": "dark-blue", "nodeNameMatchField": "Node", @@ -123,7 +123,7 @@ "dataFieldLabel": "Volume:", "dstFieldLabel": "To:", "edgeWidth": 1.5, - "endpointId": "pops", + "endpointId": "names", "legend": false, "mapjson": "{\"edges\":[],\"nodes\":[]}", "name": "Layer 2", @@ -141,7 +141,7 @@ "dataFieldLabel": "Volume:", "dstFieldLabel": "To:", "edgeWidth": 3, - "endpointId": "pops", + "endpointId": "names", "legend": false, "mapjson": "{\"edges\":[],\"nodes\":[]}", "name": "Layer 3", diff --git a/docs/development.md b/docs/development.md index 3f644c3..b803e62 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,8 +1,7 @@ ## Development Notes -This project was built in Node 16.20.2 (LTS Gallium) and must built using Yarn (1.22.0 or higher). -In order to successfully execute end-to-end (E2E) testing, 18.20.1 (LTS Hydrogen) or later is required. +This project was built in Node 18.20.4 (LTS Hydrogen) and must be built using Yarn (1.22.22 or higher). ## Table of Contents @@ -16,15 +15,20 @@ In order to successfully execute end-to-end (E2E) testing, 18.20.1 (LTS Hydrogen Pre-requisite: For local development, Grafana must be running locally as a service or as a Docker container. -1. Ensure Grafana is running. +1. Install Grafana + - Via Homebrew (Mac), `brew install grafana` should be sufficient for a locally running service. + - To install the containers in Docker, first install Docker (via `brew install docker` on Mac -or- download an + installer from https://www.docker.com/products/docker-desktop/), then setup the containers using `make compose`. + +2. Ensure Grafana is running. - On Homebrew (Mac), `brew services start grafana` should be sufficient. - Via Docker, you can start the container using `docker run -d -p 3000:3000 grafana-esnet-networkmap-panel-grafana` - On Windows, you may use Docker Desktop to run the container or enable it as a Windows service. (Hit Ctrl-R and enter `services.msc` to open the Services dialog, scroll to the Grafana entry, right-click and select Enable.) -Project setup: +Project setup (automatically done via Docker): -Pre-requisites: Both Node v16.20.2 (LTS Gallium) or later, plus Yarn 1.22.0 or higher must be installed. +Pre-requisites: Both Node v18.20.4 (LTS Hydrogen) or later, plus Yarn 1.22.22 or higher must be installed. Other versions may not build and when they do, stability issues, unexpected failures, or loss of functionality may occur. It is recommended to use [nvm](https://github.com/nvm-sh/nvm) to install and manage your Node versions. @@ -33,8 +37,8 @@ It is recommended to use [nvm](https://github.com/nvm-sh/nvm) to install and man ```sh $ node --version # check your current version, if the current node version is already v16.20.2, skip to yarn -$ nvm install 16.20.2 # this only has to be done once if using nvm, skip to yarn after installation -$ nvm use 16.20.2 # do this each time if your current node version differs +$ nvm install 18.20.4 # this only has to be done once if using nvm, skip to yarn after installation +$ nvm use 18.20.4 # do this each time if your current node version differs $ yarn install ``` @@ -51,7 +55,7 @@ plugins = /Users/myuser/grafana-plugins ;/var/lib/grafana/plugins When running it as a Docker container using docker-compose, the `docker-compose.yaml` file already maps the generated dist directory to the correct location. -Mapping only needs to be done once. Restart Grafana or the container after mapping is complete. +Mapping only needs to be done once. Restart Grafana after mapping is complete. 4. Build the project once using `make dev`. This will create source maps permit setting of breakpoints in Chrome Debugger during development. @@ -61,7 +65,7 @@ Also, only component testing will be run. Integration E2E tests must be run sepa 5. Install Playwright browsers for testing (this only needs to be done once). ```sh -$ npx playwright install # only needs to be done once +$ npx playwright install # only needs to be done once -or- when an upgrade in browsers is desired/required ``` 6. Build the project using `make prod` (`prod` is not a typo). A failure during signing is expected for local development. @@ -81,6 +85,10 @@ and already has its plugins directory mapped (see step 4), there is no need to r $ yarn run build_dts ``` +Note: All further steps may be automatically done by running `make test`; the test will create a dashboard and Network Map Panel +instance for you, using Google Sheets URLs to create a data source for the panel's topology and sample traffic flows. Read the +section [Test Execution and Reporting](#test-execution-and-reporting) prior to running `make test`. + 8. Open a browser and navigate to your Grafana instance. 9. Login and create a new dashboard or navigate to the default one. @@ -133,8 +141,8 @@ To run both component and integration tests: ```sh $ node --version # check your node version -$ nvm install 18.20.1 # if not installed, only needs to be done once, then skip to make -$ nvm use 18.20.1 # if you have installed it but the current node is not matching 18.20.1, switch to it +$ nvm install 18.20.4 # if not installed, only needs to be done once, then skip to make +$ nvm use 18.20.4 # if you have installed it but the current node is not matching 18.20.1, switch to it $ make test ``` diff --git a/e2e/edgeColoration.spec.ts b/e2e/edgeColoration.spec.ts new file mode 100644 index 0000000..d1c8edc --- /dev/null +++ b/e2e/edgeColoration.spec.ts @@ -0,0 +1,354 @@ +import { expect, Locator, Page } from '@playwright/test'; +import { createTheme } from "@grafana/data"; +import { PubSub } from "../src/components/lib/pubsub"; +import { IFlowSheet } from "../src/types"; +import { reverseStr } from "../test/utils"; +import { IFixtures } from "./interfaces/Fixtures.interface"; +import { IThreshold } from "./interfaces/Threshold.interface"; +import { BasicColorMatcher } from "./matchers/BasicColorMatcher"; +import { pluginTest as ecTest } from "./plugin-def"; +import { getEditNetworkMapPanelUrl } from "./util/urlHelpers"; +import { topologySheetUrl as topologyUrl, flowSheets } from './e2e.config.json'; +import { setupFixtures } from './util/fixtures'; + + +/** + * Returns a Promise that resolves to an array of IThreshold objects for the provided IFlowSheet inFs. + * + * @param {IFlowSheet} inFs The input flow traffic data for the given layer. + * @param {Page} page The Playwright Page provider object. + * @returns {Promise} The thresholds for the given traffic. + * @see {@link https://playwright.dev/docs/api/class-page} for Playwright API Page documentation. + */ +const getThresholds = async (inFs: IFlowSheet, page: Page): Promise => { + // only need the first swatch for the high value + let baseThresholdLoc: Locator = page.locator('#Thresholds [class*="inputWrapper"]').getByRole('button').first(); + const baseLabelAttr = await baseThresholdLoc.getAttribute('aria-label'); + expect(baseLabelAttr).toMatch(/color/); + + const thresholdsLoc = page.locator('#Thresholds'); + + // all remaining swatches work their way from high to base + const colorSwatches: Locator[] = await thresholdsLoc.getByRole('button').all(); + colorSwatches.shift(); + let thresholdsRemaining = colorSwatches.length; + let thresholdsAppended = 0; + + const resultThresholds: IThreshold[] = []; + + while (thresholdsRemaining > 0) { + const colorSwatchLoc: Locator = colorSwatches.pop()!; + + // derive color from class + const swatchAttr = colorSwatchLoc.getAttribute ? await colorSwatchLoc.getAttribute('aria-label') : null; + const attrVals = swatchAttr ? swatchAttr.split(' ') : null; + if (!attrVals || attrVals.find(val => /Remove/.test(val))) { + thresholdsRemaining--; + continue; + } + const swatchColor = attrVals.filter(s => s !== 'color').join(' '); + + // derive flow threshold level from order + let expectedFlow; + if (thresholdsRemaining === 1) { + expectedFlow = 'high'; + } else if (thresholdsRemaining > 1 && thresholdsAppended > 0) { + expectedFlow = `base+${thresholdsRemaining}`; + } else { + expectedFlow = 'base'; + } + + // derive meta data from flowsheet + const endpoint = inFs.name; + + // append to thresholds + const currentThreshold: IThreshold = { + locator: colorSwatchLoc, + expectedFlow, + swatchColor, + meta: { + type: 'edge', + endpoints: [endpoint] + } + }; + resultThresholds.push(currentThreshold); + thresholdsAppended++; + thresholdsRemaining--; + } + + return resultThresholds; +}; + +/** + * Checks the color of strokes for a given array of edge names. + * */ +const getCheckStrokeColorFn = (page: Page) => { + return async (edgeNames: string[], colorNameMatcher?: RegExp | string) => { + // actual paths in gray by default + let edgeCount = edgeNames.length; + for (const testEdge of edgeNames) { + const testEdgeAlt = testEdge.split('--').reverse().join('--'); + console.log(`Test Edge: ${testEdge}`); + // get plain edges, hence .edge class, as opposed to .control-edge + const edgeLocator: Locator = await page.locator(`.edge.edge-az-${testEdge}, .edge.edge-za-${testEdgeAlt}`); + await expect(edgeLocator).toHaveCSS('stroke', colorNameMatcher ?? BasicColorMatcher.GREY); + } + + // control paths in orange (always orange, but may not visible depending on enable/disable edge edit mode) + for (let cpIndex = 0; cpIndex < edgeCount; cpIndex++) { + const controlPathLocator: Locator = await page.locator(`.cp > path[data-index="${cpIndex}"]`); + if (await controlPathLocator.isVisible()) { + await expect(controlPathLocator).toHaveCSS('stroke', BasicColorMatcher.ORANGE); + } + } + }; +}; + +const EC_TEST_TIMEOUT = 120000; // 120sec timeout + +ecTest.describe('edge coloration', () => { + + setupFixtures(ecTest); + ecTest.setTimeout(EC_TEST_TIMEOUT); + + ecTest("test edge coloration on default load", async ({ page, fixtures }: { page: Page, fixtures: IFixtures }) => { + // load plugin edit page + const _fnName = "edgeColoration.spec['test edge coloration on default load']"; + const { targetDashboardUid, targetDashboard, targetPanelId: allFlowsPanelId, orgId } = fixtures; + const { targetPanelId: partialFlowsPanelId } = fixtures; + + // navigate to first panel for testing all flows + + expect(targetDashboard).toBeDefined(); + const editNetworkMapPanelUrl = `${await getEditNetworkMapPanelUrl(targetDashboardUid, targetDashboard!, allFlowsPanelId, orgId)}`; + await page.goto(editNetworkMapPanelUrl); + + // wait for page to load up canvas + await page.waitForSelector("[aria-label='Panel editor content'] esnet-map-canvas"); + + // on first render of a topology loaded from a remote URL + + // step 0: test default topology, should be gray + + const layer1DefaultColorDropdownSelector = '[id="Layer 1: Basic Options"] > div:nth-child(3) > div:nth-child(2) > div > div'; + let layer1DefaultColorDropdown = await page.locator(layer1DefaultColorDropdownSelector); + let layer1DefaultColorDropdownSelected = await layer1DefaultColorDropdown.innerText(); + await expect(layer1DefaultColorDropdownSelected).toMatch(BasicColorMatcher.GREY); + + const topologyNodes = ['A', 'B', 'C']; + const testEdges = topologyNodes.reduce((acc: string[], outNode: string) => { + for (const inNode of topologyNodes) { + if (inNode !== outNode) { + acc.push(`${inNode}--${outNode}`); + } + } + return acc; + }, []); + + // check color of the strokes + const checkStrokeColorFn = getCheckStrokeColorFn(page); + await checkStrokeColorFn(testEdges); + + // step 1: trigger elements to fetch config remote url containing 'normal' topology + + // update topology source to URL + const topologySidebarCtrlGrp = '[id="Network Map Panel"] [aria-label*="Topology Source"]'; + const topologySourceDropdownSelector = `${topologySidebarCtrlGrp} [role="combobox"]`; + const topologySourceDropdown = page.locator(topologySourceDropdownSelector).first(); + await topologySourceDropdown.click(); + const topologySourceUrlDropdownItem = page.getByRole('listbox').locator('[aria-label*="Select option"]', { hasText: 'URL' }); + await topologySourceUrlDropdownItem.click(); + // provide URL + const fetchConfigUrlField = await page.locator('[id="Network Map Panel"]').getByRole('textbox').first(); + await fetchConfigUrlField.fill(topologyUrl); + // trigger the load + let hasCompletedMapRender = false; + // setup promise to wait for loading event + const makeRenderPromise = (stateObj?: string, targetKey?: string) => { + return new Promise((res) => { + const handle = setInterval(() => { + if (targetKey && stateObj && stateObj[targetKey] !== undefined) { + clearInterval(handle); + res(); + } else if (hasCompletedMapRender) { + clearInterval(handle); + res(); + } + }, 100); + }); + }; + // setup subscription to watch for render event + PubSub.subscribe('renderMap', () => { + hasCompletedMapRender = true; + }, this); + makeRenderPromise(); + // trigger render + await page.locator('[id="Network Map Panel"]').first().click(); + + // step 1: navigate to second panel for testing partial flows + + const partialFlowsEditNetworkMapPanelUrl = `${await getEditNetworkMapPanelUrl(targetDashboardUid, targetDashboard!, partialFlowsPanelId, orgId)}`; + await page.goto(partialFlowsEditNetworkMapPanelUrl); + + // wait for page to load up canvas + await page.waitForSelector("[aria-label='Panel editor content'] esnet-map-canvas"); + + // step 2: turn off edit mode + await page.locator('#edge_edit_mode').click(); + + // step 3: check render of partial data + + // define helper functions + + const disableAllQueries = async () => { + const btnsToDisable: Locator[] = await page.getByTestId('query-editor-rows').getByLabel('Disable query').all(); + for (const currBtn of btnsToDisable) { + const currBtnEl = await currBtn.elementHandle(); + if (!currBtnEl) { + throw new Error(`[edgeColoration.spec.disableAllQueries]: Cannot find element handel for locator ${await currBtn.textContent()}`); + } + const ariaPressed = await currBtnEl.getAttribute('aria-pressed'); + if (ariaPressed === 'true') { + await currBtn.click(); + } + } + }; + + const enableTargetQuery = async (inFs: IFlowSheet) => { + console.info(`[edgeColoration.spec.enableTargetQuery]: inFs name: ${inFs.name}`); + const targetDisableBtn: Locator = page.getByTestId('query-editor-row').filter({ hasText: inFs.name }).getByRole('button', { name: 'Disable query' }); + const targetDisableBtnEl = await targetDisableBtn.elementHandle(); + const ariaPressed = await targetDisableBtnEl?.getAttribute('aria-pressed'); + if (ariaPressed !== 'true') { + await targetDisableBtn.click(); + } + }; + + const testFlowSheet = async (inFs: IFlowSheet) => { + + // enable only the targeted flow sheet + await disableAllQueries(); + await enableTargetQuery(inFs); + + // save dashboard changes and refresh page + // TODO: @sanchezelton, check if initial grey state for edge coloration persists after merging refactoring of render code + // if so, remove the following step + await page.getByRole('button', { name: 'save' }).click(); + await page.getByRole('button', { name: 'Dashboard settings Save Dashboard Modal Save button' }).click(); + await page.getByTestId('fdc230').click(); + + await page.getByTestId(/RefreshPicker/).click(); + + // obtain thresholds + const thresholds: IThreshold[] = await getThresholds(inFs, page); + + // check flowSheet expected threshold swatches vs. rendered values + const { visualization } = createTheme(); + let expectedFlowEdges: Locator[]; + if (inFs.name === 'All') { + expectedFlowEdges = await page.locator('.edge > .edge-az, .edge > .edge-za').all(); + } else { + expectedFlowEdges = await page.locator(`.edge-az-${inFs.name}, .edge-za-${reverseStr(inFs.name)}`).all(); + } + + let expectedStroke; + const matchingThreshhold = thresholds.find(t => t.expectedFlow === inFs.expectedFlow); + if (!matchingThreshhold) { + fail(`No matching threshold found for flowSheet ${inFs.name} with expectedFlow ${inFs.expectedFlow}`); + } else { + expectedStroke = matchingThreshhold.swatchColor; + } + + for (const expectedFlowEdge of expectedFlowEdges) { + let foundStroke = await expectedFlowEdge.getAttribute('stroke'); + if (!foundStroke?.startsWith('#')) { + foundStroke = visualization.getColorByName(expectedStroke); + } + expect(visualization.getColorByName(expectedStroke)).toEqual(foundStroke); + } + }; + + // run actual tests on flowsheet + for (const fSheet of flowSheets) { + await testFlowSheet(fSheet); + } + }); + + // TODO: @sanchezelton, see ticket TERR-421 + ecTest.skip("test edge coloration on edge preset/custom color change", async ({ page, fixtures }: { page: Page, fixtures: IFixtures }) => { + const testFlowSheet = async (inFs: IFlowSheet) => { + // obtain thresholds + const thresholds: IThreshold[] = await getThresholds(inFs, page); + const { visualization } = createTheme(); + + // check color selection change for thresholds + const swatchPrefixes = ["dark-", "semi-dark-", "", "light-", "super-light-"]; + for (const threshold of thresholds) { + if (!threshold.meta) { + throw Error('[testFlowSheet]: A returned threshold does not have any meta data for edge context'); + } + + const mapRenderState: { [swatchKey: string]: boolean } = {}; + + for (const prefix of swatchPrefixes) { + for (const baseColor of visualization.palette) { + // step 1: update the color in the threshold controls + const targetColor = `${prefix}${baseColor}`; + const targetColorHex = visualization.getColorByName(targetColor); + + // clicks the control to select the threshold + threshold.locator.click(); + // clicks the new swatch color for the threshold selected + const swatchBtn = page + .getByTestId('data-testid-colorswatch') + .getByLabel(new RegExp(targetColor)) + .first(); + mapRenderState[targetColor] = false; + const thresholdChangeWatcher = async () => { + await new Promise((res, rej) => { + document.addEventListener('thresholdsChanged', () => { + res(); + }); + }); + }; + await swatchBtn.click(); + await page.waitForFunction(thresholdChangeWatcher); + // update threshold object for consistency + threshold.swatchColor = targetColor; + + // confirm threshold object's endpoints have edges in map in the threshold's color + const edges = await page.locator('esnet-map-canvas .edge > [class*="edge"]').all(); + for (const edge of edges) { + const strokeColor = await edge.getAttribute('stroke'); + const edgeClass = await edge.getAttribute('class'); + if (!edgeClass) { + const msg = 'There is incongruence between the topology in the map and this flow traffic; this edge could be not found in the topology'; + fail(`[testFlowSheet]: ${msg}:\n${JSON.stringify(edge, null, 2)}`); + } + if (!Array.isArray(threshold.meta!.endpoints)) { + continue; + } + let hasMatchingEndpoint = false; + if (threshold.meta!.endpoints[0] === 'All') { + hasMatchingEndpoint = true; + } else { + let endpointMatchRegEx: RegExp = new RegExp(`(${threshold.meta!.endpoints.join('|')})`, 'i'); + const matchingEndpoints = threshold.meta?.endpoints; + hasMatchingEndpoint = !!matchingEndpoints?.find(ep => endpointMatchRegEx.test(ep)); + } + if (edgeClass && hasMatchingEndpoint) { + expect(strokeColor).toBe(targetColorHex); + } + } + } + } + } + }; + + // get thresholds swatch colors and expected flow levels + + for (const flowSheet of flowSheets) { + testFlowSheet(flowSheet); + } + }); +}); diff --git a/e2e/folderDashboardInit.ts b/e2e/folderDashboardInit.ts index 5d90743..ba2c2ca 100644 --- a/e2e/folderDashboardInit.ts +++ b/e2e/folderDashboardInit.ts @@ -60,7 +60,7 @@ const COLUMNS_MAP = { * * @returns {Promise} */ -export const getFolderDashboardTargets = async (params?: INetworkPanelParams): Promise => { +export const getFolderDashboardFixtures = async (params?: INetworkPanelParams): Promise => { const { basicAuthHeader, protocolHostPort } = await getHostInfo(credentials); const fnName = 'folderDashboardInit.getFolderDashboardTargets'; @@ -216,6 +216,9 @@ export const getFolderDashboardTargets = async (params?: INetworkPanelParams): P if (panel.type === "esnet-networkmap-panel") { let { options, datasource } = panel as INetworkMapPanel; // assigns the topology + if (!Array.isArray(options.layers)) { + options.layers = [{}]; + } options.layers[0].mapjson = JSON.stringify(params.topology); // traffic flow data from a data source if (params.uid) { @@ -282,6 +285,9 @@ export const removeExistingDatasources = async () => { } } +/** + * Removes all existing test dashboards of named 'network-map-test-dashboard'. If none exists, then the function's returned promise resolves. + */ export const removeExistingTestDashboards = async () => { const { basicAuthHeader, protocolHostPort } = await getHostInfo(credentials); diff --git a/e2e/mock.panel.json b/e2e/mock.panel.json index a375aee..24defc7 100644 --- a/e2e/mock.panel.json +++ b/e2e/mock.panel.json @@ -51,9 +51,9 @@ "dashboardEdgeSrcVar": "source", "dashboardNodeVar": "node", "edgeWidth": 3, - "endpointId": "pops", + "endpointId": "names", "legend": true, - "mapjson": "{\"edges\":[{\"name\":\"A--B\",\"meta\":{\"endpoint_identifiers\":{\"pops\":[\"A\",\"B\"]}},\"coordinates\":[[37.54457732085584,-98.525390625],[41.409775832009565,-89.69238281250001]],\"children\":[]},{\"name\":\"C--B\",\"meta\":{\"endpoint_identifiers\":{\"pops\":[\"C\",\"B\"]}},\"coordinates\":[[39.027718840211605,-105.64453125000001],[41.409775832009565,-89.69238281250001]],\"children\":[]},{\"name\":\"A--C\",\"meta\":{\"endpoint_identifiers\":{\"pops\":[\"A\",\"C\"]}},\"coordinates\":[[37.54457732085584,-98.525390625],[39.027718840211605,-105.64453125000001]],\"children\":[]}],\"nodes\":[{\"name\":\"A\",\"meta\":{\"display_name\":\"\",\"svg\":\"\",\"template\":\"\"},\"coordinate\":[37.54457732085584,-98.525390625]},{\"name\":\"B\",\"meta\":{\"display_name\":\"\",\"svg\":\"\",\"template\":\"\"},\"coordinate\":[41.409775832009565,-89.69238281250001]},{\"name\":\"C\",\"meta\":{\"display_name\":\"\",\"svg\":\"\",\"template\":\"\"},\"coordinate\":[39.027718840211605,-105.64453125000001]}]}", + "mapjson": "{\"edges\":[{\"name\":\"A--B\",\"meta\":{\"endpoint_identifiers\":{\"names\":[\"A\",\"B\"]}},\"coordinates\":[[37.54457732085584,-98.525390625],[41.409775832009565,-89.69238281250001]],\"children\":[]},{\"name\":\"C--B\",\"meta\":{\"endpoint_identifiers\":{\"names\":[\"C\",\"B\"]}},\"coordinates\":[[39.027718840211605,-105.64453125000001],[41.409775832009565,-89.69238281250001]],\"children\":[]},{\"name\":\"A--C\",\"meta\":{\"endpoint_identifiers\":{\"names\":[\"A\",\"C\"]}},\"coordinates\":[[37.54457732085584,-98.525390625],[39.027718840211605,-105.64453125000001]],\"children\":[]}],\"nodes\":[{\"name\":\"A\",\"meta\":{\"display_name\":\"\",\"svg\":\"\",\"template\":\"\"},\"coordinate\":[37.54457732085584,-98.525390625]},{\"name\":\"B\",\"meta\":{\"display_name\":\"\",\"svg\":\"\",\"template\":\"\"},\"coordinate\":[41.409775832009565,-89.69238281250001]},{\"name\":\"C\",\"meta\":{\"display_name\":\"\",\"svg\":\"\",\"template\":\"\"},\"coordinate\":[39.027718840211605,-105.64453125000001]}]}", "name": "Layer 1", "nodeWidth": 5, "pathOffset": 3, @@ -76,7 +76,7 @@ "dashboardEdgeSrcVar": "source", "dashboardNodeVar": "node", "edgeWidth": 3, - "endpointId": "pops", + "endpointId": "names", "legend": true, "jsonFromUrl": false, "mapjsonUrl": "", @@ -93,7 +93,7 @@ "dashboardEdgeSrcVar": "source", "dashboardNodeVar": "node", "edgeWidth": 3, - "endpointId": "pops", + "endpointId": "names", "legend": true, "jsonFromUrl": false, "mapjsonUrl": "", @@ -124,7 +124,6 @@ "tileset": { "geographic": "arcgis" }, - "useConfigurationUrl": false, "viewport": { "center": { "lat": 39, diff --git a/e2e/networkMapPanel.json b/e2e/networkMapPanel.json index c144f7d..d0cd019 100644 --- a/e2e/networkMapPanel.json +++ b/e2e/networkMapPanel.json @@ -48,7 +48,7 @@ "dataFieldLabel": "Volume:", "dstFieldLabel": "To:", "edgeWidth": 3, - "endpointId": "pops", + "endpointId": "names", "jsonFromUrl": false, "legend": true, "mapjson": "{\"edges\":[], \"nodes\":[]}", @@ -67,7 +67,7 @@ "dataFieldLabel": "Volume:", "dstFieldLabel": "To:", "edgeWidth": 3, - "endpointId": "pops", + "endpointId": "names", "jsonFromUrl": false, "legend": true, "mapjson": "{\"edges\":[], \"nodes\":[]}", @@ -86,7 +86,7 @@ "dataFieldLabel": "Volume:", "dstFieldLabel": "To:", "edgeWidth": 3, - "endpointId": "pops", + "endpointId": "names", "jsonFromUrl": false, "legend": true, "mapjson": "{\"edges\":[], \"nodes\":[]}", diff --git a/e2e/plugin-def.ts b/e2e/plugin-def.ts index 1bab4cd..cfe1d71 100644 --- a/e2e/plugin-def.ts +++ b/e2e/plugin-def.ts @@ -46,7 +46,7 @@ export interface IQuery { * The JSON model for Network Map Panel */ export interface INetworkMapPanel extends IPanel { - options: MapOptions; + options: Partial; datasource: { type: string; uid: string; }; fieldConfig?: { defaults: {[defaultFieldConfigKey: string]: any}; overrides: any[]; }; } diff --git a/e2e/plugin.spec.ts b/e2e/plugin.spec.ts index 32fd95c..b8a9dfd 100644 --- a/e2e/plugin.spec.ts +++ b/e2e/plugin.spec.ts @@ -1,51 +1,20 @@ import { expect, Locator, Page } from '@playwright/test'; -import { createTheme } from '@grafana/data'; import testIds from '../src/constants'; -import { topologySheetUrl as topologyUrl, flowSheets } from './e2e.config.json'; +import { pluginTest } from './plugin-def'; import { getHostInfo } from './config.info'; import credentials from '../playwright/.auth/credentials.json'; import { IFixtures } from './interfaces/Fixtures.interface'; -import { DEFAULT_DATASOURCE_NAME, IDashboard, pluginTest } from './plugin-def'; -import { getFolderDashboardTargets, removeExistingDatasources, removeExistingTestDashboards } from './folderDashboardInit'; -import { createDatasource } from './grafana-api'; import * as pubsub from '../src/components/lib/pubsub'; -import { BasicColorMatcher } from './matchers/BasicColorMatcher'; -import { clearInterval } from 'timers'; import { IFlowSheet } from '../src/types'; +import { getEditNetworkMapPanelUrl, getHomepageUrl } from './util/urlHelpers'; +import { setupFixtures } from './util/fixtures'; +import { BasicColorMatcher } from './matchers/BasicColorMatcher'; import { IThreshold } from './interfaces/Threshold.interface'; -import { reverseStr } from '../test/utils'; const PubSub = pubsub.PubSub; const PLUGIN_TEST_TIMEOUT = 120000; // 120s for plugin test -const getHomepageUrl = async (orgId?: string | number) => { - const { protocolHostPort } = await getHostInfo(credentials); - if (orgId) { - return `${protocolHostPort}/?orgId=${orgId}`; - } else { - return protocolHostPort; - } -} - -const getEditNetworkMapPanelUrl = async (targetDashboardUid: string, targetDb: IDashboard | string, panelId: string | number, orgId?: string | number) => { - const { protocolHostPort } = await getHostInfo(credentials); - const paramObj = { - editPanel: panelId, - 'var-node': 'ALBQ', - }; - if (!!orgId) { - paramObj['orgId'] = orgId; - } - const paramArr = Object.entries(paramObj).reduce((acc, paramVal) => { - const [param, val] = paramVal; - acc.push(`${param}=${val}`); - return acc; - }, [] as string[]); - const targetDbTitle = typeof targetDb == 'string' ? targetDb : targetDb.title; - return `${protocolHostPort}/d/${targetDashboardUid}/${targetDbTitle}?${paramArr.join('&')}`; -}; - /** * Checks the color of strokes for a given array of edge names. * */ @@ -135,44 +104,7 @@ pluginTest.describe("plugin testing", () => { pluginTest.describe.configure({ mode: "serial" }); pluginTest.setTimeout(PLUGIN_TEST_TIMEOUT); - /** - * The targets async function, part of the parameter object upon invoking pluginTest.use, - * sets up the dashboard, data sources, and panels via the Grafana API prior to running the - * test use cases. - */ - pluginTest.use({ - fixtures: async ({ }, use) => { - - // remove all data sources - await removeExistingDatasources(); - - // remove any dashboards used for test topology - await removeExistingTestDashboards(); - - // setup data sources - const dataSource = await createDatasource(DEFAULT_DATASOURCE_NAME); - - // get topology (to be used with each panel) - const topologyResponse: Response = await fetch(topologyUrl, { - redirect: 'follow' - }); - - const topology = JSON.parse(await topologyResponse.text()); - - // setup dashboard, including topology data from datasource uid - const fixtures = await getFolderDashboardTargets({ - queryType: 'tsv', - topology, - flowSheets: flowSheets as IFlowSheet[], - uid: dataSource.uid - }); - - // check panel is populated - expect(fixtures.targetPanel).not.toBeUndefined(); - - use(fixtures); - } - }); + setupFixtures(pluginTest); pluginTest("has title", async ({ page }) => { const { protocolHostPort } = await getHostInfo(); @@ -214,8 +146,6 @@ pluginTest.describe("plugin testing", () => { // Check table to have an entry with the target dashboard listed after API response await apiResponsePromise; - // const asListLocator = await page.getByTitle(/View as list/); - // asListLocator.click(); const dbTable = await page.getByRole('table'); const dbCell = dbTable.getByRole('cell', { name: /network-map-test-folder/ }); await expect(dbCell).toBeVisible(); @@ -229,7 +159,6 @@ pluginTest.describe("plugin testing", () => { }); // limited testing of sliding switches for hide/show map canvas features - // TODO: add additional e2e tests, may refactor into seperate tests pluginTest("load plugin edit page - view options", async ({ page, fixtures }: { page: Page, fixtures: IFixtures }) => { // load plugin edit page const _fnName = "plugin.spec['load plugin edit page - view options']"; @@ -291,7 +220,6 @@ pluginTest.describe("plugin testing", () => { // CHECK CONTROL ACTIONS (upon click/edit) // check Show View Controls, reassign to avoid target closure - // showViewControlsControl = page.locator('[id="View Options"] > div > div:nth-child(2) > div label').first(); await showViewControlsControl.click(); await expect(mapZoomInControl).not.toBeVisible(); await expect(mapZoomOutControl).not.toBeVisible(); @@ -304,263 +232,5 @@ pluginTest.describe("plugin testing", () => { await expect(mapEditNodeToggleControl).not.toBeVisible(); await expect(mapAddEdgeControl).not.toBeVisible(); await expect(mapAddNodeControl).not.toBeVisible(); - - // check enable node selection animation - // TODO - }); - - pluginTest("test edge coloration on default load", async ({ page, fixtures }: { page: Page, fixtures: IFixtures }) => { - // load plugin edit page - const _fnName = "plugin.spec['test edge coloration on default load']"; - const { targetDashboardUid, targetDashboard, targetPanelId: allFlowsPanelId, orgId } = fixtures; - const { targetPanelId: partialFlowsPanelId } = fixtures; - - // navigate to first panel for testing all flows - - expect(targetDashboard).toBeDefined(); - const editNetworkMapPanelUrl = `${await getEditNetworkMapPanelUrl(targetDashboardUid, targetDashboard!, allFlowsPanelId, orgId)}`; - await page.goto(editNetworkMapPanelUrl); - - // wait for page to load up canvas - await page.waitForSelector("[aria-label='Panel editor content'] esnet-map-canvas"); - - // on first render of a topology loaded from a remote URL - - // step 0: test default topology, should be gray - - // const layer1DefaultColorDropdownSelector = '[id="Layer 1: Basic Options"] > div:nth-child(3) > div:nth-child(2) > div > .css-efx5mg'; - const layer1DefaultColorDropdownSelector = '[id="Layer 1: Basic Options"] > div:nth-child(3) > div:nth-child(2) > div > div'; - let layer1DefaultColorDropdown = await page.locator(layer1DefaultColorDropdownSelector); - let layer1DefaultColorDropdownSelected = await layer1DefaultColorDropdown.innerText(); - await expect(layer1DefaultColorDropdownSelected).toMatch(BasicColorMatcher.GREY); - - const topologyNodes = ['A', 'B', 'C']; - // generate all directional edges from topologyNodes - const testEdges = topologyNodes.reduce((acc: string[], outNode: string) => { - for (const inNode of topologyNodes) { - if (inNode !== outNode) { - acc.push(`${inNode}--${outNode}`); - acc.push(`${outNode}--${inNode}`); - } - } - return acc; - }, []); - - // check color of the strokes - const checkStrokeColorFn = getCheckStrokeColorFn(page); - await checkStrokeColorFn(testEdges); - - // step 1: trigger elements to fetch config remote url containing 'normal' topology - - // enable fetching - const fetchConfigSlideButton = await page.locator('[id="Network Map Panel"] > div > div:nth-child(2) [class*="select-value-container"]').first(); - console.log(`step1: ${(await fetchConfigSlideButton.all()).length}`); - await fetchConfigSlideButton.click(); - // provide URL - const fetchConfigUrlField = await page.locator('[id="Network Map Panel"]').getByRole('combobox').first(); - await fetchConfigUrlField.fill(topologyUrl); - // trigger the load - let hasCompletedMapRender = false; - // setup promise to wait for loading event - const makeRenderPromise = (stateObj?: string, targetKey?: string) => { - return new Promise((res) => { - const handle = setInterval(() => { - if (targetKey && stateObj && stateObj[targetKey] !== undefined) { - clearInterval(handle); - res(); - } else if (hasCompletedMapRender) { - clearInterval(handle); - res(); - } - }, 100); - }); - }; - // setup subscription to watch for render event - PubSub.subscribe('renderMap', () => { - hasCompletedMapRender = true; - }, this); - makeRenderPromise(); - // trigger render - // await page.locator('[id="Network Map Panel"]').click(); - await fetchConfigUrlField.blur(); - - // check stroke colors with remotely loaded topology (should populate selected color control) - // TODO: @esanchez confer w/ @jkadafer WRT to use case where a choice color is made but a remote URL removes the color control - // layer1DefaultColorDropdown = await page.locator(layer1DefaultColorDropdownSelector); - // layer1DefaultColorDropdownSelected = await layer1DefaultColorDropdown.innerText(); - // await checkStrokeColorFn(testEdges, layer1DefaultColorDropdownSelected); - - // in a partial match situation, - // 1. Iterate through queries (no need to test default, already done above), using name of query, aka A--Z to determine edges to check - // coloration upon. The rest should be non-colored. - // 2. Tests at current only test for base vs. high thresholds - - // step 1: navigate to second panel for testing partial flows - - const partialFlowsEditNetworkMapPanelUrl = `${await getEditNetworkMapPanelUrl(targetDashboardUid, targetDashboard!, partialFlowsPanelId, orgId)}`; - await page.goto(partialFlowsEditNetworkMapPanelUrl); - - // wait for page to load up canvas - await page.waitForSelector("[aria-label='Panel editor content'] esnet-map-canvas"); - - // step 2: turn off edit mode - await page.locator('#edge_edit_mode').click(); - - // step 3: check render of partial data - - // define helper functions - - const disableAllQueries = async () => { - const btnsToDisable: Locator[] = await page.getByTestId('query-editor-rows').getByLabel('Disable query').all(); - for (const currBtn of btnsToDisable) { - const currBtnEl = await currBtn.elementHandle(); - if (!currBtnEl) { - throw new Error(`[plugin.spec.disableAllQueries]: Cannot find element handel for locator ${await currBtn.textContent()}`); - } - const ariaPressed = await currBtnEl.getAttribute('aria-pressed'); - if (ariaPressed === 'true') { - await currBtn.click(); - } - } - }; - - const enableTargetQuery = async (inFs: IFlowSheet) => { - console.info(`[plugin.spec.enableTargetQuery]: inFs name: ${inFs.name}`); - const targetDisableBtn: Locator = page.getByTestId('query-editor-row').filter({ hasText: inFs.name }).getByRole('button', { name: 'Disable query'}); - const targetDisableBtnEl = await targetDisableBtn.elementHandle(); - const ariaPressed = await targetDisableBtnEl?.getAttribute('aria-pressed'); - if (ariaPressed !== 'true') { - await targetDisableBtn.click(); - } - }; - - const testFlowSheet = async (inFs: IFlowSheet) => { - - // enable only the targeted flow sheet - await disableAllQueries(); - await enableTargetQuery(inFs); - - // save dashboard changes and refresh page - // TODO: @sanchezelton, check if initial grey state for edge coloration persists after merging refactoring of render code - // if so, remove the following step - await page.getByRole('button', { name: 'save' }).click(); - await page.getByRole('button', { name: 'Dashboard settings Save Dashboard Modal Save button' }).click(); - await page.getByTestId('fdc230').click(); - - await page.getByTestId(/RefreshPicker/).click(); - - // obtain thresholds - const thresholds: IThreshold[] = await getThresholds(inFs, page); - - // check flowSheet expected threshold swatches vs. rendered values - const { visualization } = createTheme(); - let expectedFlowEdges: Locator[]; - if (inFs.name === 'All') { - expectedFlowEdges = await page.locator('.edge > .edge-az, .edge > .edge-za').all(); - } else { - expectedFlowEdges = await page.locator(`.edge-az-${inFs.name}, .edge-za-${reverseStr(inFs.name)}`).all(); - } - - let expectedStroke; - const matchingThreshhold = thresholds.find(t => t.expectedFlow === inFs.expectedFlow); - if (!matchingThreshhold) { - fail(`No matching threshold found for flowSheet ${inFs.name} with expectedFlow ${inFs.expectedFlow}`); - } else { - expectedStroke = matchingThreshhold.swatchColor; - } - - for (const expectedFlowEdge of expectedFlowEdges) { - let foundStroke = await expectedFlowEdge.getAttribute('stroke'); - if (!foundStroke?.startsWith('#')) { - foundStroke = visualization.getColorByName(expectedStroke); - } - expect(visualization.getColorByName(expectedStroke)).toEqual(foundStroke); - } - }; - - // run actual tests on flowsheet - for (const fSheet of flowSheets) { - await testFlowSheet(fSheet); - } - }); - - // TODO: @sanchezelton, see ticket TERR-421 - pluginTest.skip("test edge coloration on edge preset/custom color change", async ({page, fixtures}: { page: Page, fixtures: IFixtures }) => { - const testFlowSheet = async (inFs: IFlowSheet) => { - - // obtain thresholds - const thresholds: IThreshold[] = await getThresholds(inFs, page); - const { visualization } = createTheme(); - - // check color selection change for thresholds - const swatchPrefixes = ["dark-", "semi-dark-", "", "light-", "super-light-"]; - for (const threshold of thresholds) { - if (!threshold.meta) { - throw Error('[testFlowSheet]: A returned threshold does not have any meta data for edge context'); - } - - const mapRenderState: {[swatchKey: string]: boolean} = {}; - - for (const prefix of swatchPrefixes) { - for (const baseColor of visualization.palette) { - // step 1: update the color in the threshold controls - const targetColor = `${prefix}${baseColor}`; - const targetColorHex = visualization.getColorByName(targetColor); - - // clicks the control to select the threshold - threshold.locator.click(); - // clicks the new swatch color for the threshold selected - const swatchBtn = page - .getByTestId('data-testid-colorswatch') - .getByLabel(new RegExp(targetColor)) - .first(); - mapRenderState[targetColor] = false; - const thresholdChangeWatcher = async () => { - await new Promise((res, rej) => { - document.addEventListener('thresholdsChanged', () => { - res(); - }); - }); - }; - await swatchBtn.click(); - await page.waitForFunction(thresholdChangeWatcher); - // update threshold object for consistency - threshold.swatchColor = targetColor; - - // confirm threshold object's endpoints have edges in map in the threshold's color - const edges = await page.locator('esnet-map-canvas .edge > [class*="edge"]').all(); - for (const edge of edges) { - const strokeColor = await edge.getAttribute('stroke'); - const edgeClass = await edge.getAttribute('class'); - if (!edgeClass) { - const msg = 'There is incongruence between the topology in the map and this flow traffic; this edge could be not found in the topology'; - fail(`[testFlowSheet]: ${msg}:\n${JSON.stringify(edge, null, 2)}`); - } - if (!Array.isArray(threshold.meta!.endpoints)) { - continue; - } - let hasMatchingEndpoint = false; - if (threshold.meta!.endpoints[0] === 'All') { - hasMatchingEndpoint = true; - } else { - let endpointMatchRegEx: RegExp = new RegExp(`(${threshold.meta!.endpoints.join('|')})`, 'i'); - const matchingEndpoints = threshold.meta?.endpoints; - hasMatchingEndpoint = !!matchingEndpoints?.find(ep => endpointMatchRegEx.test(ep)); - } - if (edgeClass && hasMatchingEndpoint) { - expect(strokeColor).toBe(targetColorHex); - } - } - } - } - } - }; - - - // get thresholds swatch colors and expected flow levels - - for (const flowSheet of flowSheets) { - testFlowSheet(flowSheet); - } }); }); diff --git a/e2e/util/fixtures.ts b/e2e/util/fixtures.ts new file mode 100644 index 0000000..0d7b52e --- /dev/null +++ b/e2e/util/fixtures.ts @@ -0,0 +1,47 @@ +import { DEFAULT_DATASOURCE_NAME } from '../plugin-def'; +import { getFolderDashboardFixtures, removeExistingDatasources, removeExistingTestDashboards } from '../folderDashboardInit'; +import { createDatasource } from '../grafana-api'; +import { topologySheetUrl as topologyUrl, flowSheets } from '../e2e.config.json'; +import { IFlowSheet } from '../../src/types'; +import { expect } from '@playwright/test'; + +export const setupFixtures = (pluginTest) => { + /** + * The targets async function, part of the parameter object upon invoking pluginTest.use, + * sets up the dashboard, data sources, and panels via the Grafana API prior to running the + * test use cases. + */ + pluginTest.use({ + fixtures: async ({ }, use) => { + + // remove all data sources + await removeExistingDatasources(); + + // remove any dashboards used for test topology + await removeExistingTestDashboards(); + + // setup data sources + const dataSource = await createDatasource(DEFAULT_DATASOURCE_NAME); + + // get topology (to be used with each panel) + const topologyResponse: Response = await fetch(topologyUrl, { + redirect: 'follow' + }); + + const topology = JSON.parse(await topologyResponse.text()); + + // setup dashboard, including topology data from datasource uid + const fixtures = await getFolderDashboardFixtures({ + queryType: 'tsv', + topology, + flowSheets: flowSheets as IFlowSheet[], + uid: dataSource.uid + }); + + // check panel is populated + expect(fixtures.targetPanel).not.toBeUndefined(); + + use(fixtures); + } + }); +}; diff --git a/e2e/util/untypedFn.js b/e2e/util/untypedFn.js new file mode 100644 index 0000000..ff66fa2 --- /dev/null +++ b/e2e/util/untypedFn.js @@ -0,0 +1,4 @@ + +export const waitForEvent = async (pageInstance, eventName, optionsOrPredicate) => { + return pageInstance.waitForEvent(eventName, optionsOrPredicate); +}; diff --git a/e2e/util/urlHelpers.ts b/e2e/util/urlHelpers.ts new file mode 100644 index 0000000..8f0c1dd --- /dev/null +++ b/e2e/util/urlHelpers.ts @@ -0,0 +1,30 @@ +import { getHostInfo } from "../config.info"; +import credentials from '../../playwright/.auth/credentials.json'; +import { IDashboard } from "../plugin-def"; + +export const getHomepageUrl = async (orgId?: string | number) => { + const { protocolHostPort } = await getHostInfo(credentials); + if (orgId) { + return `${protocolHostPort}/?orgId=${orgId}`; + } else { + return protocolHostPort; + } + } + +export const getEditNetworkMapPanelUrl = async (targetDashboardUid: string, targetDb: IDashboard | string, panelId: string | number, orgId?: string | number) => { + const { protocolHostPort } = await getHostInfo(credentials); + const paramObj = { + editPanel: panelId, + 'var-node': 'ALBQ', + }; + if (!!orgId) { + paramObj['orgId'] = orgId; + } + const paramArr = Object.entries(paramObj).reduce((acc, paramVal) => { + const [param, val] = paramVal; + acc.push(`${param}=${val}`); + return acc; + }, [] as string[]); + const targetDbTitle = typeof targetDb == 'string' ? targetDb : targetDb.title; + return `${protocolHostPort}/d/${targetDashboardUid}/${targetDbTitle}?${paramArr.join('&')}`; + }; \ No newline at end of file diff --git a/karma.webpack.conf.js b/karma.webpack.conf.js new file mode 100644 index 0000000..e0f02e5 --- /dev/null +++ b/karma.webpack.conf.js @@ -0,0 +1,127 @@ +const Path = require( "path" ); +const os = require("os"); +var webpack = require('webpack'); + +module.exports = function( config ) { + config.set( { + frameworks: [ + "jasmine", + "webpack", + ], + client: { + jasmine: { + random: false + } + }, + files: ['test/react/webpack.tests.js'], + preprocessors: { + "test/react/webpack.tests.js": ["webpack", "sourcemap"], + }, + webpack: { + mode: "development", + devtool: 'inline-source-map', // sourcemap support + output: { + filename: '[name].js', + path: Path.join(__dirname, 'test/dist/_karma_webpack_') + Math.floor(Math.random() * 1000000), + }, + externals: [ + 'emotion', + '@emotion/react', + '@emotion/css', + '@grafana/runtime', + '@grafana/slate-react', + 'react-redux', + 'redux', + 'react-router', + 'react-router-dom', + 'd3', + 'slate', + 'slate-plain-serializer', + 'prismjs', + '@grafana/ui', + 'jquery', + 'moment', + 'lodash', + 'rxjs', + 'angular', + ], + module: { + rules: [ + { + test: /src\/(?:.*\/)?module\.tsx?$/, + use: [ + { + loader: 'imports-loader', + options: { + imports: `side-effects grafana-public-path`, + }, + }, + ], + }, + { + exclude: /(node_modules)/, + test: /\.[tj]sx?$/, + use: { + loader: 'swc-loader', + options: { + jsc: { + baseUrl: Path.resolve(process.cwd(), "./src"), + target: 'es2015', + loose: false, + parser: { + syntax: 'typescript', + tsx: true, + decorators: false, + dynamicImport: true, + }, + }, + }, + }, + }, + ], + }, + resolve: { + extensions: ['.js', '.jsx', '.ts', '.tsx'], + // handle resolving "rootDir" paths + modules: [Path.resolve(process.cwd(), 'src'), 'node_modules'], + fallback: { + "fs": false, + "util": false, + "stream": false, + "tty": require.resolve("tty-browserify"), + } + }, + stats: { + modules: false, + colors: true, + }, + watch: false, + optimization: { + runtimeChunk: 'single', + splitChunks: { + chunks: 'all', + minSize: 0, + cacheGroups: { + commons: { + name: 'commons', + chunks: 'initial', + minChunks: 1, + }, + }, + }, + } + }, + client: { + captureConsole: true, + }, + reporters: ['dots'], + exclude: [ + "src/components/lib/leaflet.js", + "src/components/old/*" + ], + browsers: ["ChromeHeadless"], + singleRun: true, + webpackMiddleware: { noInfo: true }, + browserNoActivityTimeout: 60000 // 60s; wait for webpack to compile :-( + } ); +}; diff --git a/package.json b/package.json index d503bb4..7e12274 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "sign": "npx --yes @grafana/sign-plugin@latest", "start": "yarn watch", "test": "karma start --single-run --browsers ChromeHeadless", + "test:react": "karma start --single-run --browsers ChromeHeadless karma.webpack.conf.js", "test:ci": "karma start --single-run --browsers ChromeHeadless", "typecheck": "tsc --noEmit" }, @@ -56,6 +57,8 @@ "karma": "^6.4.0", "karma-chrome-launcher": "^3.1.1", "karma-jasmine": "^5.1.0", + "karma-sourcemap-loader": "^0.4.0", + "karma-webpack": "^5.0.1", "prettier": "^2.8.7", "replace-in-file-webpack-plugin": "^1.0.6", "sass": "1.63.2", @@ -66,6 +69,7 @@ "terser-webpack-plugin": "^5.3.10", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", + "tty-browserify": "^0.0.1", "typescript": "5.5.4", "webpack": "^5.94.0", "webpack-cli": "^5.1.4", diff --git a/playwright.config.ts b/playwright.config.ts index 20ebeee..e27512f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -38,7 +38,15 @@ export default defineConfig({ }, { name: 'plugin', - testMatch: 'plugin.spec.ts', + testMatch: /plugin\.spec\.ts/, + use: { + storageState: 'playwright/.auth/user.json', + }, + dependencies: ['auth'] + }, + { + name: 'edgeColoration', + testMatch: /edgeColoration\.spec\.ts/, use: { storageState: 'playwright/.auth/user.json', }, diff --git a/src/MapPanel.tsx b/src/MapPanel.tsx index 63651b8..6ea147c 100644 --- a/src/MapPanel.tsx +++ b/src/MapPanel.tsx @@ -184,11 +184,13 @@ class MapPanel extends Component { resolveNodeThresholds(options){ let thresholds: any[] = []; - for(let layer=0; layer{ + options.layers[layer]?.nodeThresholds?.steps.forEach((step) => { layerThresholds.push({ color: this.theme.visualization.getColorByName(step.color), value: step.value || 0, @@ -199,12 +201,15 @@ class MapPanel extends Component { } // snapshot the current options. If they're not the same as the last options, update them. let currOptions = JSON.parse(JSON.stringify(options)); - thresholds.forEach((layerThresholds, layerIdx)=>{ - const currLayerNodeThresholds = options?.layers?.[layerIdx].nodeThresholds; - if(JSON.stringify(layerThresholds) !== JSON.stringify(currLayerNodeThresholds)){ - setPath(currOptions, `layers[${layerIdx}].nodeThresholds`, layerThresholds); - } - }) + if (Array.isArray(thresholds)) { + thresholds.forEach((layerThresholds, layerIdx)=>{ + const currLayerNodeThresholds = options?.layers?.[layerIdx].nodeThresholds; + if(JSON.stringify(layerThresholds) !== JSON.stringify(currLayerNodeThresholds)){ + setPath(currOptions, `layers[${layerIdx}].nodeThresholds`, layerThresholds); + } + }); + } + return currOptions; } diff --git a/src/components/CustomTextArea.tsx b/src/components/CustomTextArea.tsx index 8148ca3..4c2dc58 100644 --- a/src/components/CustomTextArea.tsx +++ b/src/components/CustomTextArea.tsx @@ -1,7 +1,6 @@ -import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; +import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { StandardEditorProps, StringFieldConfigSettings } from '@grafana/data'; -import { TextArea } from '@grafana/ui'; import { monospacedFontSize } from '../options'; interface CustomTextAreaSettings extends StringFieldConfigSettings { @@ -13,6 +12,13 @@ interface Props extends StandardEditorProps { suffix?: ReactNode; } +interface ValidationState { + isPristine: boolean; + isTouched: boolean; + isValid: boolean; + errorMessage?: string; +} + function unescape(str) { return String(str) .replace(/&/g, '&') @@ -21,8 +27,70 @@ function unescape(str) { .replace(/"/g, '"'); } +function validateMapJsonStr(inStr: string, currentValidationState: ValidationState): ValidationState { + let isValid = true; + let validationFailedMsg: null | string = null; + try { + const parsedObj = JSON.parse(inStr); + if (typeof(parsedObj) != 'object') { + throw new Error("Bad topology object"); + } + if (!Array.isArray(parsedObj.edges) || !Array.isArray(parsedObj.nodes)) { + throw new Error("Missing or bad edges or nodes from topology object"); + } + for (const edge of parsedObj.edges) { + const { name, meta, coordinates } = edge; + if ( + !name || typeof(name) != 'string' || + (!!meta && typeof(meta) != 'object') || + !coordinates || !Array.isArray(coordinates) || + coordinates.some((coordinate) => { + return !Array.isArray(coordinate) + || coordinate.length != 2 + || coordinate.some((coord)=>{ return !Number.isFinite(coord)}) + }) + ) { + throw new Error("Bad edge definition"); + } + } + for (const node of parsedObj.nodes) { + const { name, meta, coordinate } = node; + if ( + !name || typeof(name) != 'string' || + (!!meta && typeof(meta) != 'object') || + !coordinate || !Array.isArray(coordinate) || + coordinate.length != 2 || !Number.isFinite(coordinate[0]) || + !Number.isFinite(coordinate[1]) + ) { + throw new Error("Bad node definition"); + } + } + } catch (e: any) { + isValid = false; + if (e instanceof Error) { + validationFailedMsg = e.message; + } + } + const newValidationState: any = { + isPristine: isValid ? currentValidationState.isPristine : false, + isTouched: isValid ? currentValidationState.isTouched : false, + isValid: isValid, + errorMessage: null, + }; + if (!isValid && validationFailedMsg) { + newValidationState.errorMessage = validationFailedMsg; + } + return newValidationState; +} + export const CustomTextArea: React.FC = ({ value, onChange, item, suffix }) => { let textareaRef = useRef(null); + let [validationState, setValidationState] = useState({ + isPristine: true, + isTouched: false, + isValid: false + } as ValidationState); + let [currentEditorValue, setCurrentEditorValue] = useState(value); const onValueChange = useCallback( (e: React.SyntheticEvent) => { @@ -30,6 +98,8 @@ export const CustomTextArea: React.FC = ({ value, onChange, item, suffix if (e.hasOwnProperty('key')) { // handling keyboard event const evt = e as React.KeyboardEvent; + // if we're not in a