Skip to content

Instantly share code, notes, and snippets.

@jjrasche
Created June 25, 2019 18:40
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jjrasche/b2952f5ef6cc5fe4ed93ad029bf19ad9 to your computer and use it in GitHub Desktop.
Save jjrasche/b2952f5ef6cc5fe4ed93ad029bf19ad9 to your computer and use it in GitHub Desktop.
// 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