Created
June 25, 2019 18:40
-
-
Save jjrasche/b2952f5ef6cc5fe4ed93ad029bf19ad9 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Angular | |
import { Type } from "@angular/core"; | |
import { TestBed, async, TestModuleMetadata } from "@angular/core/testing"; | |
import { FormGroup, FormControl } from "@angular/forms"; | |
import { Router } from "@angular/router"; | |
// Services | |
import { ConfigurationVariableService } from "@vms/services/core/configuration-variable.service"; | |
// Models | |
import { BaseTest } from "@vms/test/front-end-testing/base-test"; | |
import { LocalStorageKey } from "@vms/shared/objects/local-storage-keys"; | |
import { ConfigurationVariables, UserGeorgePermissions, EveryGeorge } from "@vms/test/front-end-testing/component-test.data"; | |
import { TestInputType, InputTest } from "@vms/test/front-end-testing/test-objects"; | |
// tslint:disable: max-line-length | |
// classes | |
export const HasError = ".has-error"; | |
export const FormControlStatic = ".form-control-static"; | |
export const DataGridRow = ".data-grid-row"; | |
export const DataGrid = ".data-grid"; | |
export const KlaPaginationCount = ".kla-pagination-count"; | |
// attributes | |
export const Disabled = "disabled"; | |
export const AriaLabel = "aria-label"; | |
export class ComponentTest<TComponent, TInputs = any> extends BaseTest { | |
public fixture: any; | |
public hostComp: any; | |
// tslint:disable-next-line | |
public _comp: any; | |
public de: any; | |
public dom: HTMLElement; | |
public configurationVariableService: ConfigurationVariableService; | |
public router: Router; | |
public formGroup: FormGroup; | |
// insures usage must specify all inputs declared in TInputs | |
public formInputs: { [key in keyof TInputs]: InputTest }; | |
constructor( | |
protected specModule: TestModuleMetadata, | |
protected testComponent: Type<any>, | |
protected hostComponent: Type<any> = null, | |
protected initializationFunction: (comp: any) => void = null) { | |
super(specModule); | |
} | |
public get comp(): TComponent { | |
return <TComponent> this._comp; | |
} | |
public set comp(instance: TComponent) { | |
this._comp = instance; | |
} | |
protected initializeTestBed(): void { | |
beforeEach(async(() => { | |
if (this.hostComponent) { | |
this.completeTestingModuleMeta.declarations.push(this.hostComponent); | |
} | |
let testBed = TestBed | |
.configureCompiler({ preserveWhitespaces: false } as any) | |
.configureTestingModule(this.completeTestingModuleMeta); | |
let setVariables: () => any = this.hostComponent == null | |
? this.handleTestComponentCreation.bind(this) | |
: this.handleHostTestComponentCreation.bind(this); | |
testBed.compileComponents().then(setVariables); | |
this.setConfigVariablesIfNeeded(); | |
this.setUserPermissionGeorges(); | |
})); | |
} | |
public handleTestComponentCreation(): void { | |
this.router = TestBed.get(Router); | |
this.fixture = TestBed.createComponent(this.testComponent); | |
this.comp = this.fixture.componentInstance; | |
this.de = this.fixture.debugElement; | |
this.dom = this.de.nativeElement; | |
if (this.initializationFunction) { | |
this.initializationFunction.bind(this)(); | |
} | |
} | |
public getDependency(objectType: any): any { | |
return TestBed.get(objectType); | |
} | |
/* In order to run the tested component through normal lifecycle processes, the inputs | |
* must be passed in via a template. So, the host component's template will pass the | |
* inputs to the actual component to test. | |
*/ | |
private handleHostTestComponentCreation(): void { | |
this.router = TestBed.get(Router); | |
this.fixture = TestBed.createComponent(this.hostComponent); | |
this.hostComp = this.fixture.componentInstance; | |
this.de = this.fixture.debugElement; | |
this.dom = this.de.nativeElement; | |
this.comp = this.hostComp.componentUnderTest; | |
if (this.initializationFunction) { | |
this.initializationFunction.bind(this)(); | |
} | |
} | |
/** | |
* GEORGE role permissions dictate what users can do throughout the app. | |
*/ | |
private setUserPermissionGeorges(userGeorgePermissions: Array<any> = UserGeorgePermissions) { | |
localStorage.setItem(LocalStorageKey.UserPermissionGeorges, JSON.stringify(userGeorgePermissions)); | |
} | |
public giveUserGeorgePermissions(permissions: Array<string>, georges: Array<number> = EveryGeorge) { | |
const georgePermissions = permissions.map(perm => { | |
return { | |
"item1": perm, | |
"item2": georges | |
}; | |
}); | |
this.setUserPermissionGeorges(georgePermissions); | |
} | |
/** | |
* Many components depend on configuraiton variables retreived in route resolvers. | |
* Injecting them here as tested components don't utilize routing. | |
*/ | |
private setConfigVariablesIfNeeded() { | |
this.configurationVariableService = TestBed.get(ConfigurationVariableService, null); | |
if (this.configurationVariableService) { | |
// retreived the following data from <appurl>/VMS50/api/configuration-variable | |
this.configurationVariableService.setVariables(ConfigurationVariables as any); | |
} | |
} | |
/** | |
* General testing methods | |
*/ | |
public getElement(selector: string, element: HTMLElement = this.dom): HTMLElement { | |
return element.querySelector(selector); | |
} | |
public getElements(selector: string, element: HTMLElement = this.dom): Array<HTMLElement> { | |
return Array.from(element.querySelectorAll(selector)); | |
} | |
public getChildByClass(element: Element, className: string): HTMLElement { | |
let ret: Element = null; | |
let children = element.children; | |
for (let i = 0; i < children.length; i++) { | |
let child = children.item(i); | |
if (child.className === className) { | |
if (ret !== null) { | |
throw new Error("Multiple child elements found with class."); | |
} | |
ret = child; | |
} | |
} | |
return ret as HTMLElement; | |
} | |
public click(element: HTMLElement, exceptionMessage: string = "element is null and cannot be clicked") { | |
if (element) { | |
element.click(); | |
} else { | |
throw Error(exceptionMessage); | |
} | |
this.wait(); | |
} | |
/** | |
* Element attribute expects | |
*/ | |
public expectDisabled = (element: HTMLElement, description: string = "element") => | |
expect(element.attributes[Disabled]).toBeTruthy(`Expected ${description} to be disabled.`) | |
public expectNotDisabled = (element: HTMLElement, description: string = "element") => | |
expect(element.attributes[Disabled]).toBeFalsy(`Expected ${description} to not be disabled.`) | |
public expectExists = (element: HTMLElement, description: string = "element") => | |
expect(element).not.toBeNull(`Expected ${description} to exist.`) | |
public expectNotExists = (element: HTMLElement, description: string = "element") => | |
expect(element).toBeNull(`Expected ${description} to not exist.`) | |
public expectSectionExpanded = (section: HTMLElement) => { | |
const toggle = this.getSectionToggleButton(section); | |
const ariaLabel = toggle.attributes[AriaLabel].value; | |
expect(ariaLabel).toContain("Collapse", `Expected section to be expanded`); | |
} | |
public expectSectionCollapsed = (section: HTMLElement) => { | |
const toggle = this.getSectionToggleButton(section); | |
const ariaLabel = toggle.attributes[AriaLabel].value; | |
expect(ariaLabel).toContain("Expand", `Expected section to be collapsed`); | |
} | |
// whether an ngx-kla-bootsrtap-form input is editable depends on the type of dom element surrounding it | |
// a <p> tag indicates this is text and not a form type of field | |
public inputIsReadonly = (key: keyof TInputs) => this.getFormElement(key).tagName === "P"; | |
public expectInputEditable = (key: keyof TInputs, description: string = "element") => | |
expect(this.inputIsReadonly(key)).toBeFalsy(`Expected ${description} to be editable.`) | |
public expectInputReadonly = (key: keyof TInputs, description: string = "element") => | |
expect(this.inputIsReadonly(key)).toBeTruthy(`Expected ${description} to not be editable.`) | |
/** | |
* Dom element manipulation methods | |
*/ | |
public setInputElement(element: HTMLInputElement, value: string): void { | |
element.value = value; | |
element.dispatchEvent(new Event("input")); | |
this.wait(); | |
} | |
public setCheckBoxElement(element: HTMLInputElement, value: boolean): void { | |
element.checked = value; | |
element.dispatchEvent(new Event("input")); | |
this.wait(); | |
} | |
// TODO: the change isn't visually represented, but it is made correctly in the form control | |
public selectOption(select: HTMLSelectElement, value: string) { | |
const optionValue = this.findMatchingOption(select, value).value; | |
select.value = optionValue; | |
select.dispatchEvent(new Event("change")); | |
this.wait(); | |
} | |
private findMatchingOption(select: HTMLSelectElement, value: string): HTMLOptionElement { | |
const options = Array.from(select.options); | |
const option = options.find(opt => opt.value.includes(value)); | |
if (!option) { | |
throw new Error(`option with value:${value}, not found in select.`); | |
} | |
return option; | |
} | |
public selectOptionElementClick(select: HTMLSelectElement, value: string) { | |
select.click(); | |
this.wait(); | |
const option = select.querySelector(`option[value~='${value}']`) as HTMLOptionElement; | |
if (!option) { | |
throw Error(`option with value ${value} not found on selector ${select.id}`); | |
} | |
option.click(); | |
this.wait(); | |
} | |
public select2Option(controlName: string, value: any, formGroup: FormGroup = (this.comp as any).formGroup) { | |
const control = formGroup.get(controlName); | |
control.setValue(value); | |
this.wait(); | |
} | |
/** | |
* Form interaction methods | |
*/ | |
public getInput(key: keyof TInputs): InputTest { | |
return this.formInputs[key]; | |
} | |
/** | |
* Changed this to look at the label and find an input or p so a form | |
* element will be returned even if it is readonly. | |
* | |
* This is highly coupled with the structure of our ngx-kla-form-element library, | |
* but I needed some context | |
*/ | |
public getFormElement(key: keyof TInputs): HTMLElement { | |
const input = this.getInput(key); | |
const label = this.getElement(`*[for^="${input.id}"`); | |
if (!label) { | |
throw new Error(`label not found for input ${key}`); | |
} | |
return label.nextElementSibling.querySelector("select,textarea,input,select2,p") as HTMLElement; | |
} | |
public getFormWrapper(key: keyof TInputs): HTMLElement { | |
const input = this.getInput(key); | |
return this.getElement(`kla-detail-wrapper[ng-reflect-label-for^="${input.id}"`); | |
} | |
public setFormElements(inputs: { [key in keyof TInputs]?: any }) { | |
Object.keys(inputs).forEach(key => { | |
this.setFormElement(key as keyof TInputs, inputs[key]); | |
}); | |
} | |
public setFormElement(key: keyof TInputs, value: any, formGroup: FormGroup = (this.comp as any).formGroup) { | |
const input: InputTest = this.getInput(key); | |
switch (input.type) { | |
case TestInputType.Input: | |
this.setInputElement(this.getFormElement(key) as HTMLInputElement, value); | |
break; | |
case TestInputType.Select: | |
this.selectOption(this.getFormElement(key) as HTMLSelectElement, value); | |
break; | |
case TestInputType.Select2: | |
this.select2Option(input.controlName, value); | |
break; | |
case TestInputType.CheckBox: | |
this.setCheckBoxElement(this.getFormElement(key) as HTMLInputElement, value); | |
break; | |
default: | |
throw new Error(`type ${input.type} is not valid for "setInput"`); | |
} | |
this.wait(); | |
} | |
public getFormElementValue(key: keyof TInputs): string | boolean { | |
const input: InputTest = this.getInput(key); | |
switch (input.type) { | |
case TestInputType.Input: | |
return (this.getFormElement(key) as HTMLInputElement).value.trim(); | |
case TestInputType.Select: | |
return (this.getFormElement(key) as HTMLSelectElement).selectedOptions[0].textContent.trim(); | |
case TestInputType.Select2: | |
const select2 = this.getFormElement(key); | |
return select2.children[1].textContent.trim(); | |
case TestInputType.CheckBox: | |
return (this.getFormElement(key) as HTMLInputElement).checked; | |
default: | |
throw new Error(`type ${input.type} is not valid for "getInputValue"`); | |
} | |
} | |
public blurFormElement(key: keyof TInputs) { | |
const input = this.getInput(key); | |
const element = this.getElement(`*[id^="${input.id}"`); | |
if (!element) { | |
throw new Error(`Did not find element for input ${key}`); | |
} | |
element.dispatchEvent(new Event("blur")); // the only way I've found to set a control as `touched` | |
this.wait(); | |
} | |
public setAndValidateFormElement(key: keyof TInputs, value: any, expectedvalue: any, shouldError: boolean = false) { | |
this.setFormElement(key, value); | |
this.blurFormElement(key); | |
expect(this.getFormElementValue(key)).toEqual(expectedvalue); | |
this.expectValidation(key, shouldError); | |
} | |
public expectValidation(key: keyof TInputs, shouldError: boolean = true) { | |
shouldError ? this.expectExists(this.getFormWrapper(key).querySelector(HasError), "help block error") : | |
this.expectNotExists(this.getFormWrapper(key).querySelector(HasError), "help block error"); | |
} | |
public getActualEditableFormValue() { | |
return Object.keys(this.formInputs) | |
.filter(key => !this.inputIsReadonly(key as keyof TInputs)) | |
.map(key => this.getFormElementValue(key as keyof TInputs)); | |
} | |
public getSelectOptionValues(element: HTMLElement, removePlaceholder: boolean = true) { | |
const select = element as HTMLSelectElement; | |
if (!select.options) { | |
throw new Error("Element is not a Select Element."); | |
} | |
let ret = Array.from(select.options).map(opt => opt.textContent.trim()); | |
if (removePlaceholder) { | |
ret.shift(); | |
} | |
return ret; | |
} | |
public getFormEditButton = (element: HTMLElement = this.dom) => { | |
let editButton = this.getElement(`button[title~='edit'i]`, element); | |
if (!editButton) { | |
editButton = this.getElement("kla-edit-button>kla-base-button>button"); | |
} | |
return editButton; | |
} | |
public getFormSaveButton = (element: HTMLElement = this.dom) => { | |
let button = this.getElement(`button[title~='Save']`, element); | |
if (!button) { | |
button = this.getElement("kla-save-cancel-button>kla-base-button>button"); | |
} | |
return button; | |
} | |
public getFormCancelButton = (element: HTMLElement = this.dom) => this.getElement(`button[title='Cancel'],[title='Close']`, element); | |
public clickCancelButton = () => this.click(this.getFormCancelButton()); | |
public clickEditButton = () => this.click(this.getFormEditButton()); | |
public clickSaveButton = () => this.click(this.getFormSaveButton()); | |
public compareReadOnlyFormValues(expected: Array<string>, rootElement: HTMLElement = this.dom) { | |
let elements = Array.from(rootElement.querySelectorAll(FormControlStatic)); | |
let actual: Array<string> = []; | |
elements.forEach(element => { | |
actual.push(element.textContent.trim()); | |
}); | |
this.objectEquality(actual, expected); | |
} | |
public expectFieldsToBeEditable(expectedEditableFields: Array<keyof TInputs>) { | |
let actualEditableFields: Array<string> = []; | |
expectedEditableFields.forEach(key => { | |
const wrapper = this.getFormWrapper(key); | |
const inputHoldingDiv = wrapper.querySelector("label").nextElementSibling; | |
const readOnlyElement = inputHoldingDiv.querySelector(FormControlStatic); | |
const numberElementsWithinInputDivMinusHelpBlock = inputHoldingDiv.children.length - 1; | |
// ensure an actual input/readonly element exists and it is not readonly | |
if (numberElementsWithinInputDivMinusHelpBlock > 0 && !readOnlyElement) { | |
actualEditableFields.push(key as string); | |
} | |
}); | |
this.objectEquality(actualEditableFields, expectedEditableFields); | |
} | |
// Form methods | |
public getInputFormControl(key: keyof TInputs): FormControl { | |
if (!this.formGroup) { | |
throw new Error(("FormGroup must be passed in to use getInputFormControl")); | |
} | |
const controlName = this.formInputs[key].controlName; | |
const control = this.formGroup.get(controlName); | |
return control as FormControl; | |
} | |
/** | |
* Section methods | |
*/ | |
// finds any button with aria-label either "Expand *** Section" or "Collapse *** Section" | |
public toggleSection(parentElment: HTMLElement = this.dom): void { | |
this.wait(); | |
const button = this.getSectionToggleButton(parentElment); | |
button.click(); | |
this.wait(); | |
} | |
public getSectionToggleButton(parentElment: HTMLElement): HTMLButtonElement { | |
return parentElment.querySelector(`button[aria-label^='Expand'][aria-label$='Section'], | |
[aria-label^='Collapse'][aria-label$='Section']`); | |
} | |
/** | |
* Datagrid related methods | |
*/ | |
public getDataGrid(element: HTMLElement = this.dom): HTMLElement { | |
return this.getElement(DataGrid, element); | |
} | |
public getRows(element: HTMLElement = this.dom): Array<HTMLElement> { | |
return this.getElements(DataGridRow, element); | |
} | |
public getRow(rowNumber: number, element: HTMLElement = this.dom): HTMLElement { | |
const rows = this.getRows(element); | |
if (rows[rowNumber]) { | |
return rows[rowNumber]; | |
} | |
throw Error(`a row ${rowNumber} does not exist, only ${rows.length} rows in grid`); | |
} | |
public getRowExpanderContent(rowNumber: number, element: HTMLElement = this.dom): HTMLElement { | |
const row = this.getRow(rowNumber, element); | |
return row.nextElementSibling as HTMLElement; | |
} | |
public getRowColumnValue(rowNumber: number, column: number, element: HTMLElement = this.dom): any { | |
const row = this.getRow(rowNumber, element); | |
const columns = Array.from(row.querySelectorAll("div[class^='data-grid-col']")); | |
return (columns[column] as HTMLDivElement).textContent.trim(); | |
} | |
public getRowDataGrid(rowNumber: number, element: HTMLElement = this.dom): HTMLElement { | |
const rowExpander = this.getRowExpanderContent(rowNumber, element); | |
return this.getElement(DataGrid, rowExpander); | |
} | |
public getRowForm(rowNumber: number, element: HTMLElement = this.dom, formSelector: string = "*"): HTMLElement { | |
const rowExpander = this.getRowExpanderContent(rowNumber, element); | |
return this.getElement(formSelector, rowExpander); | |
} | |
public getRowFormSaveButton(rowNumber: number, element: HTMLElement = this.dom) { | |
const form = this.getRowForm(rowNumber, element); | |
return this.getElement(`button[title='Save']`, form); | |
} | |
public getRowToggleElement(rowNumber: number, element: HTMLElement = this.dom): HTMLElement { | |
return this.getElement(`div[title='Expand Row'],[title='Collapse Row']`, this.getRow(rowNumber, element)); | |
} | |
public getRowFormCancelButton = (rowNumber: number, element: HTMLElement = this.dom) => this.getFormCancelButton(this.getRowForm(rowNumber, element)); | |
public getRowEditButton = (rowNumber: number, element: HTMLElement = this.dom) => this.getFormEditButton(this.getRow(rowNumber, element)); | |
public clickRowEditButton = (rowNumber: number, element: HTMLElement = this.dom) => this.click(this.getRowEditButton(rowNumber, element), `No edit button in row ${rowNumber}`); | |
public clickRowSaveButton = (rowNumber: number, element: HTMLElement = this.dom) => this.click(this.getRowFormSaveButton(rowNumber, element)); | |
public clickRowToggle = (rowNumber: number, element: HTMLElement = this.dom) => this.click(this.getRowToggleElement(rowNumber, element)); | |
public compareGridRow(actualRow: Element, expectedRow: string[]) { | |
// iterate over data-grid-row running inner text on all gridcells | |
let row = []; | |
let cells = Array.from(actualRow.querySelectorAll(` | |
div[role='gridcell']:not(.data-grid-button-col):not(.data-grid-row-expander):not(.pointer)`)); | |
for (let i = 0; i < cells.length; i++) { | |
let cell = cells[i]; | |
row.push((cell as any).innerText); | |
} | |
this.expectObjectEqual(row, expectedRow); | |
// // for testing | |
// let p = ""; | |
// row.forEach((c, idx) => p += idx === row.length - 1 ? `"${c}"` : `"${c}", `); | |
// return p; | |
} | |
public compareGridRows(tableElement: Element, expectedRows: string[][]) { | |
let actualRows = Array.from(tableElement.querySelectorAll(DataGridRow)); | |
expect(actualRows.length).toEqual(expectedRows.length); | |
let p = ""; | |
actualRows.forEach((actualRow, idx) => { | |
let rowString = `[${this.compareGridRow(actualRow, expectedRows[idx])}]`; | |
p += idx === actualRows.length - 1 ? `${rowString}\n` : `${rowString},\n`; | |
}); | |
// // for testing | |
// console.log(p); | |
} | |
public expectAllRows(check: (row: HTMLElement, idx: number) => void) { | |
this.getRows().forEach((row: HTMLElement, idx: number) => { | |
check(row, idx); | |
}); | |
} | |
/** | |
* Pagination | |
*/ | |
public hasPagination(): boolean { | |
return this.getElement(KlaPaginationCount) != null; | |
} | |
public expectPagination(start: number, end: number, total: number) { | |
expect(this.paginationStartRecordNumber()).toEqual(start); | |
expect(this.paginationEndRecordNunmber()).toEqual(end); | |
expect(this.paginationTotalNumberOfRecords()).toEqual(total); | |
} | |
public getPaginationPhrase(): string { | |
return this.getElement(KlaPaginationCount).innerText; | |
} | |
public paginationTotalNumberOfRecords(): number { | |
let matches = this.getPaginationPhrase().match(/of (\d+)/); | |
return parseInt(matches[1], 10); | |
} | |
public paginationStartRecordNumber(): number { | |
let matches = this.getPaginationPhrase().match(/Displaying records (\d+) -/); | |
return parseInt(matches[1], 10); | |
} | |
public paginationEndRecordNunmber(): number { | |
let matches = this.getPaginationPhrase().match(/- (\d+) of/); | |
return parseInt(matches[1], 10); | |
} | |
public sortColumn(ariaLabelName: string) { | |
let idSortButton = this.getElement(`a[aria-label^='Sort by ${ariaLabelName}']`); | |
idSortButton.click(); | |
this.wait(); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment