From 9c846ba3672e9a24f0b0c63660c34ea2ab338c8c Mon Sep 17 00:00:00 2001 From: Lucille Vu Date: Mon, 10 Feb 2025 21:29:25 -0500 Subject: [PATCH] test(quantic): SFINT-5832 Sort E2E tests migrate from Cypress to Playwright (#4777) https://coveord.atlassian.net/browse/SFINT-5832 **IN THIS PR:** - Added Playwright E2E tests for the quantic-sort component **UNIT TESTS:** - No need, as UTs already there **E2E PLAYWRIGHT TESTS:** - Playwright for Sort component --------- Co-authored-by: mmitiche Co-authored-by: Etienne Rocheleau Co-authored-by: Simon Milord --- .../quanticSort/__tests__/quanticSort.test.js | 255 +++++++++++++++--- .../default/lwc/quanticSort/e2e/fixture.ts | 61 +++++ .../default/lwc/quanticSort/e2e/pageObject.ts | 89 ++++++ .../lwc/quanticSort/e2e/quanticSort.e2e.ts | 91 +++++++ .../default/lwc/quanticSort/quanticSort.js | 11 +- 5 files changed, 466 insertions(+), 41 deletions(-) create mode 100644 packages/quantic/force-app/main/default/lwc/quanticSort/e2e/fixture.ts create mode 100644 packages/quantic/force-app/main/default/lwc/quanticSort/e2e/pageObject.ts create mode 100644 packages/quantic/force-app/main/default/lwc/quanticSort/e2e/quanticSort.e2e.ts diff --git a/packages/quantic/force-app/main/default/lwc/quanticSort/__tests__/quanticSort.test.js b/packages/quantic/force-app/main/default/lwc/quanticSort/__tests__/quanticSort.test.js index 06c38f3510b..3db00e5c020 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticSort/__tests__/quanticSort.test.js +++ b/packages/quantic/force-app/main/default/lwc/quanticSort/__tests__/quanticSort.test.js @@ -5,6 +5,13 @@ import QuanticSort from 'c/quanticSort'; import {createElement} from 'lwc'; import * as mockHeadlessLoader from 'c/quanticHeadlessLoader'; +const selectors = { + lightningCombobox: 'lightning-combobox', + componentError: 'c-quantic-component-error', + sortDropdown: '[data-testid="sort-dropdown"]', + initializationError: 'c-quantic-component-error', +}; + const sortVariants = { default: { name: 'default', @@ -20,25 +27,40 @@ const sortVariants = { }, }; +const sortByLabel = 'Sort By'; + jest.mock('c/quanticHeadlessLoader'); -jest.mock('@salesforce/label/c.quantic_SortBy', () => ({default: 'Sort By'}), { - virtual: true, -}); +jest.mock( + '@salesforce/label/c.quantic_SortBy', + () => ({default: sortByLabel}), + { + virtual: true, + } +); -function mockBueno() { - jest.spyOn(mockHeadlessLoader, 'getBueno').mockReturnValue( - new Promise(() => { - // @ts-ignore - global.Bueno = { - isString: jest - .fn() - .mockImplementation( - (value) => - Object.prototype.toString.call(value) === '[object String]' - ), - }; - }) - ); +function mockBueno(shouldError = false) { + // @ts-ignore + mockHeadlessLoader.getBueno = () => { + // @ts-ignore + global.Bueno = { + isString: jest + .fn() + .mockImplementation( + (value) => Object.prototype.toString.call(value) === '[object String]' + ), + StringValue: jest.fn(), + RecordValue: jest.fn(), + Schema: jest.fn(() => ({ + validate: () => { + if (shouldError) { + throw new Error(); + } + jest.fn(); + }, + })), + }; + return new Promise((resolve) => resolve()); + }; } let isInitialized = false; @@ -47,32 +69,35 @@ const exampleEngine = { id: 'exampleEngineId', }; -const mockSearchStatusState = { +const defaultSearchStatusState = { hasResults: true, }; - -const mockSearchStatus = { - state: mockSearchStatusState, - subscribe: jest.fn((callback) => { - callback(); - return jest.fn(); - }), -}; +let searchStatusState = defaultSearchStatusState; const functionsMocks = { buildSort: jest.fn(() => ({ state: {}, - subscribe: functionsMocks.subscribe, + subscribe: functionsMocks.sortStateSubscriber, + sortBy: functionsMocks.sortBy, + })), + buildSearchStatus: jest.fn(() => ({ + state: searchStatusState, + subscribe: functionsMocks.searchStatusStateSubscriber, })), buildCriterionExpression: jest.fn((criterion) => criterion), buildRelevanceSortCriterion: jest.fn(() => 'relevance'), buildDateSortCriterion: jest.fn(() => 'date'), - buildSearchStatus: jest.fn(() => mockSearchStatus), - subscribe: jest.fn((cb) => { + sortStateSubscriber: jest.fn((cb) => { cb(); - return functionsMocks.unsubscribe; + return functionsMocks.sortStateUnsubscriber; }), - unsubscribe: jest.fn(() => {}), + searchStatusStateSubscriber: jest.fn((cb) => { + cb(); + return functionsMocks.searchStatusStateUnsubscriber; + }), + sortStateUnsubscriber: jest.fn(), + searchStatusStateUnsubscriber: jest.fn(), + sortBy: jest.fn(), }; const defaultOptions = { @@ -80,10 +105,19 @@ const defaultOptions = { variant: 'default', }; -const expectedSortByLabel = 'Sort By'; +/** + * Mocks the return value of the assignedNodes method. + * @param {Array} assignedElements + */ +function mockSlotAssignedNodes(assignedElements) { + HTMLSlotElement.prototype.assignedNodes = function () { + return assignedElements; + }; +} -function createTestComponent(options = defaultOptions) { +function createTestComponent(options = defaultOptions, assignedElements = []) { prepareHeadlessState(); + mockSlotAssignedNodes(assignedElements); const element = createElement('c-quantic-sort', { is: QuanticSort, @@ -128,6 +162,15 @@ function mockSuccessfulHeadlessInitialization() { }; } +function mockErroneousHeadlessInitialization() { + // @ts-ignore + mockHeadlessLoader.initializeWithHeadless = (element) => { + if (element instanceof QuanticSort) { + element.setInitializationError(); + } + }; +} + function cleanup() { // The jsdom instance is shared across test cases in a single file so reset the DOM while (document.body.firstChild) { @@ -145,14 +188,152 @@ describe('c-quantic-sort', () => { afterEach(() => { cleanup(); + searchStatusState = defaultSearchStatusState; + }); + + describe('when an initialization error occurs', () => { + beforeEach(() => { + mockErroneousHeadlessInitialization(); + }); + + it('should display the initialization error component', async () => { + const element = createTestComponent(); + await flushPromises(); + + const initializationError = element.shadowRoot.querySelector( + selectors.initializationError + ); + + expect(initializationError).not.toBeNull(); + }); }); describe('controller initialization', () => { - it('should subscribe to the headless state changes', async () => { + it('should subscribe to the headless sort and search status state changes', async () => { createTestComponent(); await flushPromises(); - expect(functionsMocks.subscribe).toHaveBeenCalledTimes(1); + expect(functionsMocks.sortStateSubscriber).toHaveBeenCalledTimes(1); + expect(functionsMocks.searchStatusStateSubscriber).toHaveBeenCalledTimes( + 1 + ); + }); + }); + + describe('when no results are found', () => { + beforeAll(() => { + searchStatusState = {...defaultSearchStatusState, hasResults: false}; + }); + + it('should not display the sort dropdown', async () => { + const element = createTestComponent(); + await flushPromises(); + + const sortDropdown = element.shadowRoot.querySelector( + selectors.sortDropdown + ); + + expect(sortDropdown).toBeNull(); + }); + }); + + describe('when a sort option is selected', () => { + it('should call the sortBy method of the sort controller', async () => { + const element = createTestComponent(); + await flushPromises(); + + const lightningCombobox = element.shadowRoot.querySelector( + selectors.lightningCombobox + ); + const exampleDefaultSortOptionValue = 'relevance'; + expect(lightningCombobox).not.toBeNull(); + + lightningCombobox.dispatchEvent( + new CustomEvent('change', { + detail: {value: exampleDefaultSortOptionValue}, + }) + ); + + expect(functionsMocks.sortBy).toHaveBeenCalledTimes(1); + expect(functionsMocks.sortBy).toHaveBeenCalledWith( + exampleDefaultSortOptionValue + ); + }); + }); + + describe('when custom sort options are passed', () => { + const exampleSlot = { + value: 'example value', + label: 'example label', + criterion: { + by: 'example field', + order: 'example order', + }, + }; + const exampleAssignedElements = [exampleSlot]; + + it('should build the controller with the correct sort option and display the custom sort options', async () => { + const element = createTestComponent( + defaultOptions, + exampleAssignedElements + ); + await flushPromises(); + + expect(functionsMocks.buildSort).toHaveBeenCalledTimes(1); + expect(functionsMocks.buildSort).toHaveBeenCalledWith(exampleEngine, { + initialState: { + criterion: { + by: 'example field', + order: 'example order', + }, + }, + }); + const lightningCombobox = element.shadowRoot.querySelector( + selectors.lightningCombobox + ); + + expect(lightningCombobox.options).toEqual([exampleSlot]); + }); + }); + + describe('when invalid sort options are passed', () => { + beforeEach(() => { + mockBueno(true); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + const invalidExampleSlot = { + value: 'example value', + label: '', + criterion: { + by: 'example field', + order: 'example order', + }, + }; + const exampleAssignedElements = [invalidExampleSlot]; + + it('should display the component error', async () => { + const element = createTestComponent( + defaultOptions, + exampleAssignedElements + ); + await flushPromises(); + + expect(functionsMocks.buildSort).toHaveBeenCalledTimes(1); + expect(functionsMocks.buildSort).toHaveBeenCalledWith(exampleEngine, { + initialState: { + criterion: { + by: 'example field', + order: 'example order', + }, + }, + }); + const componentError = element.shadowRoot.querySelector( + selectors.componentError + ); + + expect(componentError).not.toBeNull(); + expect(console.error).toHaveBeenCalledTimes(1); }); }); @@ -187,7 +368,7 @@ describe('c-quantic-sort', () => { const sortLabel = element.shadowRoot.querySelector(variant.labelSelector); - expect(sortLabel.value).toBe(expectedSortByLabel); + expect(sortLabel.value).toBe(sortByLabel); }); }); @@ -228,7 +409,7 @@ describe('c-quantic-sort', () => { const sortLabel = element.shadowRoot.querySelector(variant.labelSelector); - expect(sortLabel.textContent).toBe(expectedSortByLabel); + expect(sortLabel.textContent).toBe(sortByLabel); expect(sortLabel.classList).toContain('slds-text-heading_small'); }); }); diff --git a/packages/quantic/force-app/main/default/lwc/quanticSort/e2e/fixture.ts b/packages/quantic/force-app/main/default/lwc/quanticSort/e2e/fixture.ts new file mode 100644 index 00000000000..fb0f0d1dc4d --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticSort/e2e/fixture.ts @@ -0,0 +1,61 @@ +import {SortObject} from './pageObject'; +import {quanticBase} from '../../../../../../playwright/fixtures/baseFixture'; +import {SearchObject} from '../../../../../../playwright/page-object/searchObject'; +import { + searchRequestRegex, + insightSearchRequestRegex, +} from '../../../../../../playwright/utils/requests'; +import {InsightSetupObject} from '../../../../../../playwright/page-object/insightSetupObject'; +import {useCaseEnum} from '../../../../../../playwright/utils/useCase'; + +const sortUrl = 's/quantic-sort'; + +interface SortOptions {} + +type QuanticSortE2EFixtures = { + sort: SortObject; + search: SearchObject; + options: Partial; +}; + +type QuanticSortE2ESearchFixtures = QuanticSortE2EFixtures & { + urlHash: string; +}; + +type QuanticSortE2EInsightFixtures = QuanticSortE2EFixtures & { + insightSetup: InsightSetupObject; +}; + +export const testSearch = quanticBase.extend({ + options: {}, + urlHash: '', + search: async ({page}, use) => { + await use(new SearchObject(page, searchRequestRegex)); + }, + sort: async ({page, options, configuration, search, urlHash}, use) => { + await page.goto(urlHash ? `${sortUrl}#${urlHash}` : sortUrl); + configuration.configure(options); + await search.waitForSearchResponse(); + await use(new SortObject(page)); + }, +}); + +export const testInsight = quanticBase.extend({ + options: {}, + search: async ({page}, use) => { + await use(new SearchObject(page, insightSearchRequestRegex)); + }, + insightSetup: async ({page}, use) => { + await use(new InsightSetupObject(page)); + }, + sort: async ({page, options, search, configuration, insightSetup}, use) => { + await page.goto(sortUrl); + configuration.configure({...options, useCase: useCaseEnum.insight}); + await insightSetup.waitForInsightInterfaceInitialization(); + await search.performSearch(); + await search.waitForSearchResponse(); + await use(new SortObject(page)); + }, +}); + +export {expect} from '@playwright/test'; diff --git a/packages/quantic/force-app/main/default/lwc/quanticSort/e2e/pageObject.ts b/packages/quantic/force-app/main/default/lwc/quanticSort/e2e/pageObject.ts new file mode 100644 index 00000000000..7efd2238f3d --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticSort/e2e/pageObject.ts @@ -0,0 +1,89 @@ +import type {Locator, Page, Request} from '@playwright/test'; +import {isUaSearchEvent} from '../../../../../../playwright/utils/requests'; + +export class SortObject { + constructor(public page: Page) { + this.page = page; + } + + get sortDropDown(): Locator { + return this.page.getByRole('combobox', {name: 'Sort by'}); + } + + get sortPreviewButton(): Locator { + return this.page.getByRole('button', {name: 'Preview'}); + } + + sortButton(buttonName: string): Locator { + return this.page.getByRole('option', {name: buttonName}); + } + + async clickSortDropDown(): Promise { + await this.sortDropDown.click(); + } + + async focusSortDropDown(): Promise { + await this.sortPreviewButton.click(); + await this.page.keyboard.press('Tab'); + } + + async clickSortButton(buttonName: string): Promise { + await this.sortButton(buttonName).click(); + } + + async openSortDropdownUsingKeyboardEnter(useEnter = true): Promise { + if (useEnter) { + await this.page.keyboard.press('Enter'); + } else { + await this.page.keyboard.press('Space'); + } + await this.sortButton('Oldest').isVisible(); + await this.page.waitForTimeout(500); + } + + async waitForSortUaAnalytics(eventValue: any): Promise { + const uaRequest = this.page.waitForRequest((request) => { + if (isUaSearchEvent(request)) { + const requestBody = request.postDataJSON(); + const expectedFields = { + actionCause: 'resultsSort', + customData: { + resultsSortBy: eventValue, + }, + }; + + const validateObject = (obj: any, expectedResult: any): boolean => + Object.entries(expectedResult).every(([key, value]) => + value && typeof value === 'object' + ? validateObject(obj?.[key], value) + : obj?.[key] === value + ); + + return validateObject(requestBody, expectedFields); + } + return false; + }); + return uaRequest; + } + + async allSortLabelOptions() { + const options = await this.page + .locator('div[part="dropdown overlay"]') + .allInnerTexts(); + const arrSort = options[0].split('\n'); + return arrSort; + } + + async getSortLabelValue() { + const arrSort = await this.allSortLabelOptions(); + const sortLabelwithValueList = await Promise.all( + arrSort.map(async (item) => ({ + label: item, + value: await this.page + .getByRole('option', {name: item}) + .getAttribute('data-value'), + })) + ); + return sortLabelwithValueList; + } +} diff --git a/packages/quantic/force-app/main/default/lwc/quanticSort/e2e/quanticSort.e2e.ts b/packages/quantic/force-app/main/default/lwc/quanticSort/e2e/quanticSort.e2e.ts new file mode 100644 index 00000000000..23403fbcd22 --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticSort/e2e/quanticSort.e2e.ts @@ -0,0 +1,91 @@ +import {testSearch, testInsight, expect} from './fixture'; +import { + useCaseEnum, + useCaseTestCases, +} from '../../../../../../playwright/utils/useCase'; + +const fixtures = { + search: testSearch as typeof testSearch, + insight: testInsight as typeof testInsight, +}; + +useCaseTestCases.forEach((useCase) => { + let test; + let sortArrOptions; + let sortLabelWithValue; + if (useCase.value === useCaseEnum.search) { + test = fixtures[useCase.value] as typeof testSearch; + } else { + test = fixtures[useCase.value] as typeof testInsight; + } + + test.describe(`quantic sort ${useCase.label}`, () => { + test.beforeEach(async ({sort}) => { + await sort.clickSortDropDown(); + sortArrOptions = await sort.allSortLabelOptions(); + sortLabelWithValue = await sort.getSortLabelValue(); + await sort.clickSortDropDown(); + }); + + test.describe(`when changing sort option to 2nd position`, () => { + test('should trigger a new search and log analytics', async ({ + sort, + search, + }) => { + const {label: expectedSortName, value: expectedSortValue} = + sortLabelWithValue[1]; // Assuming 0 is the first sort option, 1 should be the 2nd one after that. + + const searchResponsePromise = search.waitForSearchResponse(); + await sort.clickSortDropDown(); + await sort.clickSortButton(expectedSortName); + + const searchResponse = await searchResponsePromise; + const {sortCriteria} = searchResponse.request().postDataJSON(); + expect(sortCriteria).toBe(expectedSortValue); + await sort.waitForSortUaAnalytics(expectedSortValue); + }); + }); + + test.describe('when testing accessibility', () => { + test('should be accessible to keyboard', async ({sort, page}) => { + let selectedSortLabel = await sort.sortDropDown.textContent(); + expect(selectedSortLabel).toEqual(sortArrOptions[0]); + + // Selecting the next sort using Enter to open dropdown, the ArrowDown, then ENTER key + await sort.focusSortDropDown(); + await sort.openSortDropdownUsingKeyboardEnter(); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + selectedSortLabel = await sort.sortDropDown.textContent(); + expect(selectedSortLabel).toEqual(sortArrOptions[1]); + + // Selecting the next sort using Space to open dropdown, the ArrowDown, then ENTER key + await sort.focusSortDropDown(); + await sort.openSortDropdownUsingKeyboardEnter(false); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + selectedSortLabel = await sort.sortDropDown.textContent(); + expect(selectedSortLabel).toEqual(sortArrOptions[2]); + }); + }); + + if (useCase.value === 'search') { + test.describe('when loading options from the url', () => { + test('should reflect the options of url in the component', async ({ + sort, + page, + }) => { + const {label: expectedSortName, value: expectedSortValue} = + sortLabelWithValue[2]; // Assuming 0 is the first sort option + + const currentUrl = await page.url(); + const urlHash = `#sortCriteria=${encodeURI(expectedSortValue)}`; + + await page.goto(`${currentUrl}/${urlHash}`); + await page.getByRole('button', {name: 'Try it now'}).click(); + await expect(sort.sortDropDown).toContainText(expectedSortName); + }); + }); + } + }); +}); diff --git a/packages/quantic/force-app/main/default/lwc/quanticSort/quanticSort.js b/packages/quantic/force-app/main/default/lwc/quanticSort/quanticSort.js index e7a99a42ec3..335142962c8 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticSort/quanticSort.js +++ b/packages/quantic/force-app/main/default/lwc/quanticSort/quanticSort.js @@ -167,11 +167,12 @@ export default class QuanticSort extends LightningElement { } /** - * @param {CustomEvent<{value: string}>} e + * @param {CustomEvent<{value: string}>} event */ - handleChange(e) { + handleChange(event) { this.sort.sortBy( - this.options.find((option) => option.value === e.detail.value).criterion + this.options.find((option) => option.value === event.detail.value) + .criterion ); } @@ -280,8 +281,10 @@ export default class QuanticSort extends LightningElement { * @returns {SortOption[]} The specified custom sort options. */ get customSortOptions() { + /** @type {HTMLSlotElement} */ + const slot = this.template.querySelector('slot[name="sortOption"]'); /** @type {SortOption[]} */ - return Array.from(this.querySelectorAll('c-quantic-sort-option')).map( + return Array.from(slot?.assignedNodes()).map( // @ts-ignore ({label, value, criterion}) => ({label, value, criterion}) );