fix: DisplayLayout and FlexibleLayout toolbar actions only apply to selected layout (#7184)

* refactor: convert to ES6 function

* fix: include `keyString` in event name

- This negates the need for complicated logic in determining which objectView the action was intended for

* fix: handle the case of currentView being null

* fix: add keyString to flexibleLayout toolbar events

* fix: properly unregister listeners

* fix: remove unused imports

* fix: revert parameter reorder

* refactor: replace usage of `arguments` with `...args`

* fix: add a11y to display layout + toolbar

* test: add first cut of layout toolbar suite

* test: cleanup a bit and add Image test

* test: add stubs

* fix: remove unused variable

* refactor(DisplayLayoutToolbar): convert to ES6 class

* test: generate localStorage data for display layout tests

* fix: clarify "Add" button label

* test: cleanup and don't parameterize tests

* test: fix path for recycled_local_storage.json

* fix: path to local storage file

* docs: add documentation for
utilizing localStorage in e2e

* fix: path to recycled_local_storage.json

* docs: add note hyperlink
This commit is contained in:
Jesse Mazzella 2023-11-02 13:42:37 -07:00 committed by GitHub
parent bdff210a9c
commit 02f1013770
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1175 additions and 858 deletions

View File

@ -193,7 +193,7 @@ Current list of test tags:
|`@ipad` | Test case or test suite is compatible with Playwright's iPad support and Open MCT's read-only mobile view (i.e. no create button).|
|`@gds` | Denotes a GDS Test Case used in the VIPER Mission.|
|`@addInit` | Initializes the browser with an injected and artificial state. Useful for loading non-default plugins. Likely will not work outside of `npm start`.|
|`@localStorage` | Captures or generates session storage to manipulate browser state. Useful for excluding in tests which require a persistent backend (i.e. CouchDB).|
|`@localStorage` | Captures or generates session storage to manipulate browser state. Useful for excluding in tests which require a persistent backend (i.e. CouchDB). See [note](#utilizing-localstorage)|
|`@snapshot` | Uses Playwright's snapshot functionality to record a copy of the DOM for direct comparison. Must be run inside of the playwright container.|
|`@unstable` | A new test or test which is known to be flaky.|
|`@2p` | Indicates that multiple users are involved, or multiple tabs/pages are used. Useful for testing multi-user interactivity.|
@ -352,6 +352,28 @@ By adhering to this principle, we can create tests that are both robust and refl
1. Avoid repeated setup to test a single assertion. Write longer tests with multiple soft assertions.
This ensures that your changes will be picked up with large refactors.
##### Utilizing LocalStorage
1. In order to save test runtime in the case of tests that require a decent amount of initial setup (such as in the case of testing complex displays), you may use [Playwright's `storageState` feature](https://playwright.dev/docs/api/class-browsercontext#browser-context-storage-state) to generate and load localStorage states.
1. To generate a localStorage state to be used in a test:
- Add an e2e test to our generateLocalStorageData suite which sets the initial state (creating/configuring objects, etc.), saving it in the `test-data` folder:
```js
// Save localStorage for future test execution
await context.storageState({
path: path.join(__dirname, '../../../e2e/test-data/display_layout_with_child_layouts.json')
});
```
- Load the state from file at the beginning of the desired test suite (within the `test.describe()`). (NOTE: the storage state will be used for each test in the suite, so you may need to create a new suite):
```js
const LOCALSTORAGE_PATH = path.resolve(
__dirname,
'../../../../test-data/display_layout_with_child_layouts.json'
);
test.use({
storageState: path.resolve(__dirname, LOCALSTORAGE_PATH)
});
```
### How to write a great test
- Avoid using css locators to find elements to the page. Use modern web accessible locators like `getByRole`

File diff suppressed because one or more lines are too long

View File

@ -55,6 +55,42 @@ test.describe('Generate Visual Test Data @localStorage @generatedata', () => {
await page.goto('./', { waitUntil: 'domcontentloaded' });
});
test('Generate display layout with 2 child display layouts', async ({ page, context }) => {
// Create Display Layout
const parent = await createDomainObjectWithDefaults(page, {
type: 'Display Layout',
name: 'Parent Display Layout'
});
const child1 = await createDomainObjectWithDefaults(page, {
type: 'Display Layout',
name: 'Child Layout 1',
parent: parent.uuid
});
const child2 = await createDomainObjectWithDefaults(page, {
type: 'Display Layout',
name: 'Child Layout 2',
parent: parent.uuid
});
await page.goto(parent.url);
await page.getByLabel('Edit').click();
await page.getByLabel(`${child2.name} Layout Grid`).hover();
await page.getByLabel('Move Sub-object Frame').nth(1).click();
await page.getByLabel('X:').fill('30');
await page.getByLabel(`${child1.name} Layout Grid`).hover();
await page.getByLabel('Move Sub-object Frame').first().click();
await page.getByLabel('Y:').fill('30');
await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
//Save localStorage for future test execution
await context.storageState({
path: path.join(__dirname, '../../../e2e/test-data/display_layout_with_child_layouts.json')
});
});
// TODO: Visual test for the generated object here
// - Move to using appActions to create the overlay plot
// and embedded standard telemetry object

View File

@ -19,7 +19,7 @@
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/* global __dirname */
/*
This test suite is dedicated to tests which verify the basic operations surrounding conditionSets. Note: this
suite is sharing state between tests which is considered an anti-pattern. Implementing in this way to
@ -31,6 +31,7 @@ const {
createDomainObjectWithDefaults,
createExampleTelemetryObject
} = require('../../../../appActions');
const path = require('path');
let conditionSetUrl;
let getConditionSetIdentifierFromUrl;
@ -48,7 +49,9 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => {
await Promise.all([page.waitForNavigation(), page.click('button:has-text("OK")')]);
//Save localStorage for future test execution
await context.storageState({ path: './e2e/test-data/recycled_local_storage.json' });
await context.storageState({
path: path.resolve(__dirname, '../../../../test-data/recycled_local_storage.json')
});
//Set object identifier from url
conditionSetUrl = page.url();
@ -59,7 +62,9 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => {
});
//Load localStorage for subsequent tests
test.use({ storageState: './e2e/test-data/recycled_local_storage.json' });
test.use({
storageState: path.resolve(__dirname, '../../../../test-data/recycled_local_storage.json')
});
//Begin suite of tests again localStorage
test('Condition set object properties persist in main view and inspector @localStorage', async ({

View File

@ -19,8 +19,9 @@
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/* global __dirname */
const { test, expect } = require('../../../../pluginFixtures');
const path = require('path');
const {
createDomainObjectWithDefaults,
setStartOffset,
@ -29,6 +30,88 @@ const {
setIndependentTimeConductorBounds
} = require('../../../../appActions');
const LOCALSTORAGE_PATH = path.resolve(
__dirname,
'../../../../test-data/display_layout_with_child_layouts.json'
);
const TINY_IMAGE_BASE64 =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
test.describe('Display Layout Toolbar Actions', () => {
const PARENT_DISPLAY_LAYOUT_NAME = 'Parent Display Layout';
const CHILD_DISPLAY_LAYOUT_NAME1 = 'Child Layout 1';
test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' });
await setRealTimeMode(page);
await page
.locator('a')
.filter({ hasText: 'Parent Display Layout Display Layout' })
.first()
.click();
await page.getByLabel('Edit').click();
});
test.use({
storageState: path.resolve(__dirname, LOCALSTORAGE_PATH)
});
test('can add/remove Text element to a single layout', async ({ page }) => {
const layoutObject = 'Text';
await test.step(`Add and remove ${layoutObject} from the parent's layout`, async () => {
await addAndRemoveDrawingObjectAndAssert(page, layoutObject, PARENT_DISPLAY_LAYOUT_NAME);
});
await test.step(`Add and remove ${layoutObject} from the child's layout`, async () => {
await addAndRemoveDrawingObjectAndAssert(page, layoutObject, CHILD_DISPLAY_LAYOUT_NAME1);
});
});
test('can add/remove Image to a single layout', async ({ page }) => {
const layoutObject = 'Image';
await test.step("Add and remove image element from the parent's layout", async () => {
expect(await page.getByLabel(`Move ${layoutObject} Frame`).count()).toBe(0);
await addLayoutObject(page, PARENT_DISPLAY_LAYOUT_NAME, layoutObject);
expect(await page.getByLabel(`Move ${layoutObject} Frame`).count()).toBe(1);
await removeLayoutObject(page, layoutObject);
expect(await page.getByLabel(`Move ${layoutObject} Frame`).count()).toBe(0);
});
await test.step("Add and remove image from the child's layout", async () => {
await addLayoutObject(page, CHILD_DISPLAY_LAYOUT_NAME1, layoutObject);
expect(await page.getByLabel(`Move ${layoutObject} Frame`).count()).toBe(1);
await removeLayoutObject(page, layoutObject);
expect(await page.getByLabel(`Move ${layoutObject} Frame`).count()).toBe(0);
});
});
test(`can add/remove Box to a single layout`, async ({ page }) => {
const layoutObject = 'Box';
await test.step(`Add and remove ${layoutObject} from the parent's layout`, async () => {
await addAndRemoveDrawingObjectAndAssert(page, layoutObject, PARENT_DISPLAY_LAYOUT_NAME);
});
await test.step(`Add and remove ${layoutObject} from the child's layout`, async () => {
await addAndRemoveDrawingObjectAndAssert(page, layoutObject, CHILD_DISPLAY_LAYOUT_NAME1);
});
});
test(`can add/remove Line to a single layout`, async ({ page }) => {
const layoutObject = 'Line';
await test.step(`Add and remove ${layoutObject} from the parent's layout`, async () => {
await addAndRemoveDrawingObjectAndAssert(page, layoutObject, PARENT_DISPLAY_LAYOUT_NAME);
});
await test.step(`Add and remove ${layoutObject} from the child's layout`, async () => {
await addAndRemoveDrawingObjectAndAssert(page, layoutObject, CHILD_DISPLAY_LAYOUT_NAME1);
});
});
test(`can add/remove Ellipse to a single layout`, async ({ page }) => {
const layoutObject = 'Ellipse';
await test.step(`Add and remove ${layoutObject} from the parent's layout`, async () => {
await addAndRemoveDrawingObjectAndAssert(page, layoutObject, PARENT_DISPLAY_LAYOUT_NAME);
});
await test.step(`Add and remove ${layoutObject} from the child's layout`, async () => {
await addAndRemoveDrawingObjectAndAssert(page, layoutObject, CHILD_DISPLAY_LAYOUT_NAME1);
});
});
test.fixme('Can switch view types of a single SWG in a layout', async ({ page }) => {});
test.fixme('Can merge multiple plots in a layout', async ({ page }) => {});
test.fixme('Can adjust stack order of a single object in a layout', async ({ page }) => {});
test.fixme('Can duplicate a single object in a layout', async ({ page }) => {});
});
test.describe('Display Layout', () => {
/** @type {import('../../../../appActions').CreatedObjectInfo} */
let sineWaveObject;
@ -41,6 +124,7 @@ test.describe('Display Layout', () => {
type: 'Sine Wave Generator'
});
});
test('alpha-numeric widget telemetry value exactly matches latest telemetry value received in real time', async ({
page
}) => {
@ -339,6 +423,59 @@ test.describe('Display Layout', () => {
});
});
async function addAndRemoveDrawingObjectAndAssert(page, layoutObject, DISPLAY_LAYOUT_NAME) {
expect(await page.getByLabel(layoutObject, { exact: true }).count()).toBe(0);
await addLayoutObject(page, DISPLAY_LAYOUT_NAME, layoutObject);
expect(
await page
.getByLabel(layoutObject, {
exact: true
})
.count()
).toBe(1);
await removeLayoutObject(page, layoutObject);
expect(await page.getByLabel(layoutObject, { exact: true }).count()).toBe(0);
}
/**
* Remove the first matching layout object from the layout
* @param {import('@playwright/test').Page} page
* @param {'Box' | 'Ellipse' | 'Line' | 'Text' | 'Image'} layoutObject
*/
async function removeLayoutObject(page, layoutObject) {
await page
.getByLabel(`Move ${layoutObject} Frame`, { exact: true })
.or(page.getByLabel(layoutObject, { exact: true }))
.first()
// eslint-disable-next-line playwright/no-force-option
.click({ force: true });
await page.getByTitle('Delete the selected object').click();
await page.getByRole('button', { name: 'OK' }).click();
}
/**
* Add a layout object to the specified layout
* @param {import('@playwright/test').Page} page
* @param {string} layoutName
* @param {'Box' | 'Ellipse' | 'Line' | 'Text' | 'Image'} layoutObject
*/
async function addLayoutObject(page, layoutName, layoutObject) {
await page.getByLabel(`${layoutName} Layout Grid`).click();
await page.getByText('Add Drawing Object').click();
await page
.getByRole('menuitem', {
name: layoutObject
})
.click();
if (layoutObject === 'Text') {
await page.getByRole('textbox', { name: 'Text' }).fill('Hello, Universe!');
await page.getByText('OK').click();
} else if (layoutObject === 'Image') {
await page.getByLabel('Image URL').fill(TINY_IMAGE_BASE64);
await page.getByText('OK').click();
}
}
/**
* Util for subscribing to a telemetry object by object identifier
* Limitations: Currently only works to return telemetry once to the node scope

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,9 @@
class="c-box-view u-style-receiver js-style-receiver"
:class="[styleClass]"
:style="style"
role="application"
aria-roledescription="draggable box"
aria-label="Box"
></div>
</template>
</layout-frame>

View File

@ -36,6 +36,8 @@
:grid-size="gridSize"
:show-grid="showGrid"
:grid-dimensions="gridDimensions"
:aria-label="`${domainObject.name} Layout Grid`"
:aria-hidden="showGrid ? 'false' : 'true'"
/>
<div
v-if="shouldDisplayLayoutDimensions"

View File

@ -20,7 +20,14 @@
at runtime from the About dialog for additional information.
-->
<template>
<div class="l-layout__grid-holder" :class="{ 'c-grid': showGrid }">
<div
class="l-layout__grid-holder"
:class="{ 'c-grid': showGrid }"
role="grid"
:aria-label="'Layout Grid'"
:aria-hidden="showGrid ? 'false' : 'true'"
:aria-live="showGrid ? 'polite' : 'off'"
>
<div
v-if="gridSize[0] >= 3"
class="c-grid__x l-grid l-grid-x"

View File

@ -33,6 +33,9 @@
class="c-ellipse-view u-style-receiver js-style-receiver"
:class="[styleClass]"
:style="style"
role="application"
aria-roledescription="draggable ellipse"
aria-label="Ellipse"
></div>
</template>
</layout-frame>

View File

@ -30,13 +30,18 @@
:style="style"
>
<slot name="content"></slot>
<div class="c-frame__move-bar" @mousedown.left="startMove($event)"></div>
<div
class="c-frame__move-bar"
:aria-label="`Move ${typeName} Frame`"
@mousedown.left="startMove($event)"
></div>
</div>
</template>
<script>
import _ from 'lodash';
import DRAWING_OBJECT_TYPES from '../DrawingObjectTypes';
import LayoutDrag from './../LayoutDrag';
export default {
@ -58,6 +63,13 @@ export default {
},
emits: ['move', 'end-move'],
computed: {
typeName() {
const { type } = this.item;
if (DRAWING_OBJECT_TYPES[type]) {
return DRAWING_OBJECT_TYPES[type].name;
}
return 'Sub-object';
},
size() {
let { width, height } = this.item;

View File

@ -21,7 +21,14 @@
-->
<template>
<div class="l-layout__frame c-frame no-frame c-line-view" :class="[styleClass]" :style="style">
<div
class="l-layout__frame c-frame no-frame c-line-view"
:class="[styleClass]"
:style="style"
aria-role="application"
aria-roledescription="draggable line"
aria-label="Line"
>
<svg width="100%" height="100%">
<line
v-bind="linePosition"

View File

@ -35,6 +35,9 @@
:data-font="item.font"
:class="[styleClass]"
:style="style"
role="application"
aria-roledescription="draggable text"
aria-label="Text"
>
<div class="c-text-view__text">{{ item.text }}</div>
</div>

View File

@ -85,10 +85,9 @@ class DisplayLayoutView {
};
}
contextAction() {
const action = arguments[0];
if (this.component && this.component.$refs.displayLayout[action]) {
this.component.$refs.displayLayout[action](...Array.from(arguments).splice(1));
contextAction(action, ...rest) {
if (this?.component.$refs.displayLayout[action]) {
this.component.$refs.displayLayout[action](...rest);
}
}

View File

@ -81,10 +81,9 @@ export default class FlexibleLayoutViewProvider {
type: 'flexible-layout'
};
},
contextAction() {
const action = arguments[0];
if (component && component.$refs.flexibleLayout[action]) {
component.$refs.flexibleLayout[action](...Array.from(arguments).splice(1));
contextAction(action, ...args) {
if (component?.$refs?.flexibleLayout?.[action]) {
component.$refs.flexibleLayout[action](...args);
}
},
onEditModeChange(isEditing) {

View File

@ -84,7 +84,8 @@ function ToolbarProvider(openmct) {
let containerIndex = containers.indexOf(container);
let frame = container && container.frames.filter((f) => f.id === frameId)[0];
let frameIndex = container && container.frames.indexOf(frame);
const primaryKeyString = openmct.objects.makeKeyString(primary.context.item.identifier);
const tertiaryKeyString = openmct.objects.makeKeyString(tertiary.context.item.identifier);
deleteFrame = {
control: 'button',
domainObject: primary.context.item,
@ -98,7 +99,7 @@ function ToolbarProvider(openmct) {
emphasis: 'true',
callback: function () {
openmct.objectViews.emit(
'contextAction',
`contextAction:${primaryKeyString}`,
'deleteFrame',
primary.context.frameId
);
@ -135,11 +136,12 @@ function ToolbarProvider(openmct) {
}
]
};
addContainer = {
control: 'button',
domainObject: tertiary.context.item,
method: function () {
openmct.objectViews.emit('contextAction', 'addContainer', ...arguments);
method: function (...args) {
openmct.objectViews.emit(`contextAction:${tertiaryKeyString}`, 'addContainer', ...args);
},
key: 'add',
icon: 'icon-plus-in-rect',
@ -185,11 +187,14 @@ function ToolbarProvider(openmct) {
title: 'Remove Container'
};
const domainObject = secondary.context.item;
const keyString = openmct.objects.makeKeyString(domainObject.identifier);
addContainer = {
control: 'button',
domainObject: secondary.context.item,
method: function () {
openmct.objectViews.emit('contextAction', 'addContainer', ...arguments);
domainObject,
method: function (...args) {
openmct.objectViews.emit(`contextAction:${keyString}`, 'addContainer', ...args);
},
key: 'add',
icon: 'icon-plus-in-rect',
@ -200,11 +205,14 @@ function ToolbarProvider(openmct) {
return [];
}
const domainObject = primary.context.item;
const keyString = openmct.objects.makeKeyString(domainObject.identifier);
addContainer = {
control: 'button',
domainObject: primary.context.item,
method: function () {
openmct.objectViews.emit('contextAction', 'addContainer', ...arguments);
domainObject,
method: function (...args) {
openmct.objectViews.emit(`contextAction:${keyString}`, 'addContainer', ...args);
},
key: 'add',
icon: 'icon-plus-in-rect',

View File

@ -34,9 +34,6 @@ import { STYLE_CONSTANTS } from '@/plugins/condition/utils/constants';
import stalenessMixin from '@/ui/mixins/staleness-mixin';
export default {
components: {
// IndependentTimeConductor
},
mixins: [stalenessMixin],
inject: ['openmct'],
props: {
@ -181,7 +178,9 @@ export default {
this.triggerUnsubscribeFromStaleness(this.domainObject);
this.openmct.objectViews.off('clearData', this.clearData);
this.openmct.objectViews.off('contextAction', this.performContextAction);
if (this.contextActionEvent) {
this.openmct.objectViews.off(this.contextActionEvent, this.performContextAction);
}
},
getStyleReceiver() {
let styleReceiver;
@ -301,8 +300,11 @@ export default {
);
}
this.contextActionEvent = `contextAction:${this.openmct.objects.makeKeyString(
this.domainObject.identifier
)}`;
this.openmct.objectViews.on('clearData', this.clearData);
this.openmct.objectViews.on('contextAction', this.performContextAction);
this.openmct.objectViews.on(this.contextActionEvent, this.performContextAction);
this.$nextTick(() => {
this.updateStyle(this.styleRuleManager?.currentStyle);
@ -473,9 +475,9 @@ export default {
}
}
},
performContextAction() {
if (this.currentView.contextAction) {
this.currentView.contextAction(...arguments);
performContextAction(...args) {
if (this?.currentView?.contextAction) {
this.currentView.contextAction(...args);
}
},
isEditingAllowed() {

View File

@ -31,15 +31,19 @@
{{ options.label }}
</div>
</div>
<div v-if="open" class="c-menu">
<div v-if="open" class="c-menu" role="menu">
<ul>
<li
v-for="(option, index) in options.options"
:key="index"
:class="option.class"
role="menuitem"
:aria-labelledby="`${option.name}-menuitem-label`"
@click="onClick(option)"
>
{{ option.name }}
<span :id="`${option.name}-menuitem-label`">
{{ option.name }}
</span>
</li>
</ul>
</div>