diff --git a/packages/quantic/README.md b/packages/quantic/README.md index 2096e3f3179..18955d7d4d4 100644 --- a/packages/quantic/README.md +++ b/packages/quantic/README.md @@ -144,7 +144,7 @@ npm run e2e:detailed To run Playwright tests, run: ```bash -npm run e2e:playwright: +npm run e2e:playwright ``` To run Playwright tests only for the scratch org where LWS is enabled, run: diff --git a/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-selectors.ts b/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-selectors.ts index b54ae841203..9cfb0448ee3 100644 --- a/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-selectors.ts +++ b/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-selectors.ts @@ -36,7 +36,9 @@ export const GeneratedAnswerSelectors: GeneratedAnswerSelector = { get: () => cy.get(generatedAnswerComponent), generatedAnswerCard: () => - GeneratedAnswerSelectors.get().find('[data-cy="generated-answer__card"]'), + GeneratedAnswerSelectors.get().find( + '[data-testid="generated-answer__card"]' + ), generatedAnswer: () => GeneratedAnswerSelectors.get().find('[data-cy="generated-answer__answer"]'), likeButton: () => @@ -49,23 +51,23 @@ export const GeneratedAnswerSelectors: GeneratedAnswerSelector = { ), citations: () => GeneratedAnswerSelectors.get().find( - '[data-cy="generated-answer__citations"]' + '[data-testid="generated-answer__citations"]' ), citationTitle: (index: number) => GeneratedAnswerSelectors.get() - .find('[data-cy="generated-answer__citations"] .citation__title') + .find('[data-testid="generated-answer__citations"] .citation__title') .eq(index), citationLink: (index: number) => GeneratedAnswerSelectors.get() - .find('[data-cy="generated-answer__citations"] .citation__link') + .find('[data-testid="generated-answer__citations"] .citation__link') .eq(index), retryButton: () => GeneratedAnswerSelectors.get().find( - '[data-cy="generated-answer__retry-button"]' + '[data-testid="generated-answer__retry-button"]' ), toggleGeneratedAnswerButton: () => GeneratedAnswerSelectors.get().find( - 'c-quantic-generated-answer-toggle [data-cy="generated-answer__toggle-button"]' + 'c-quantic-generated-answer-toggle [data-testid="generated-answer__toggle-button"]' ), generatedAnswerContent: () => GeneratedAnswerSelectors.get().find( @@ -96,38 +98,38 @@ export const GeneratedAnswerSelectors: GeneratedAnswerSelector = { ), copyToClipboardButton: () => GeneratedAnswerSelectors.get().find( - '[data-cy="generated-answer__copy-to-clipboard"]' + '[data-testid="generated-answer__copy-to-clipboard"]' ), citationTooltip: (index: number) => GeneratedAnswerSelectors.get() - .find('[data-cy="generated-answer__citations"] [data-cy="tooltip"]') + .find('[data-testid="generated-answer__citations"] [data-cy="tooltip"]') .eq(index), citationTooltipUri: (index: number) => GeneratedAnswerSelectors.get() .find( - '[data-cy="generated-answer__citations"] [data-cy="citation__tooltip-uri"]' + '[data-testid="generated-answer__citations"] [data-cy="citation__tooltip-uri"]' ) .eq(index), citationTooltipTitle: (index: number) => GeneratedAnswerSelectors.get() .find( - '[data-cy="generated-answer__citations"] [data-cy="citation__tooltip-title"]' + '[data-testid="generated-answer__citations"] [data-cy="citation__tooltip-title"]' ) .eq(index), citationTooltipText: (index: number) => GeneratedAnswerSelectors.get() .find( - '[data-cy="generated-answer__citations"] [data-cy="citation__tooltip-text"]' + '[data-testid="generated-answer__citations"] [data-cy="citation__tooltip-text"]' ) .eq(index), disclaimer: () => GeneratedAnswerSelectors.get().find( - '[data-cy="generated-answer__disclaimer"]' + '[data-testid="generated-answer__disclaimer"]' ), toggleCollapseButton: () => GeneratedAnswerSelectors.get().find( - '[data-cy="generated-answer__answer-toggle"]' + '[data-testid="generated-answer__answer-toggle"]' ), generatingMessage: () => GeneratedAnswerSelectors.get().find( diff --git a/packages/quantic/force-app/main/default/lwc/quanticFeedbackModalQna/templates/quanticFeedbackModalQna.html b/packages/quantic/force-app/main/default/lwc/quanticFeedbackModalQna/templates/quanticFeedbackModalQna.html index d134d301b54..fe637cf1de8 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticFeedbackModalQna/templates/quanticFeedbackModalQna.html +++ b/packages/quantic/force-app/main/default/lwc/quanticFeedbackModalQna/templates/quanticFeedbackModalQna.html @@ -17,6 +17,7 @@
{question.question} diff --git a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/__tests__/quanticGeneratedAnswer.test.js b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/__tests__/quanticGeneratedAnswer.test.js index 519d036acf6..42333a3940f 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/__tests__/quanticGeneratedAnswer.test.js +++ b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/__tests__/quanticGeneratedAnswer.test.js @@ -4,11 +4,36 @@ import {createElement} from 'lwc'; import QuanticGeneratedAnswer from 'c/quanticGeneratedAnswer'; import * as mockHeadlessLoader from 'c/quanticHeadlessLoader'; +let mockAnswerHeight = 300; + jest.mock('c/quanticHeadlessLoader'); +jest.mock('c/quanticUtils', () => ({ + AriaLiveRegion: jest.fn(() => ({ + dispatchMessage: jest.fn(), + })), + loadMarkdownDependencies: jest.fn( + () => + new Promise((resolve) => { + resolve(); + }) + ), + getAbsoluteHeight: jest.fn(() => { + return mockAnswerHeight; + }), + I18nUtils: { + format: jest.fn(), + }, +})); -function createTestComponent(options = {}) { - prepareHeadlessState(); +/** @type {Object} */ +const defaultOptions = { + fieldsToIncludeInCitations: 'sfid,sfkbid,sfkavid', + answerConfigurationId: undefined, + withToggle: false, + collapsible: false, +}; +function createTestComponent(options = defaultOptions) { const element = createElement('c-quantic-generated-answer', { is: QuanticGeneratedAnswer, }); @@ -20,27 +45,52 @@ function createTestComponent(options = {}) { return element; } +const selectors = { + initializationError: 'c-quantic-component-error', + generatedAnswerCard: '[data-testid="generated-answer__card"]', + generatedAnswerBadge: '[data-testid="generated-answer__badge"]', + generatedAnswerRetryButton: '[data-testid="generated-answer__retry-button"]', + generatedAnswerActions: '[data-testid="generated-answer__actions"]', + generatedAnswerToggleButton: 'c-quantic-generated-answer-toggle', + generatedAnswerContent: 'c-quantic-generated-answer-content', + generatingMessageWhenAnswerCollapsed: + '[data-testid="generated-answer__collapse-generating-message"]', + generatedAnswerCollapseToggle: + '[data-testid="generated-answer__answer-toggle"]', + generatedAnswerDisclaimer: '[data-testid="generated-answer__disclaimer"]', +}; + +const initialSearchStatusState = { + hasError: false, +}; +let searchStatusState = initialSearchStatusState; + +const initialGeneratedAnswerState = {isVisible: true}; +let generatedAnswerState = initialGeneratedAnswerState; + const functionsMocks = { buildGeneratedAnswer: jest.fn(() => ({ - subscribe: jest.fn((callback) => callback()), - state: {}, + state: generatedAnswerState, + subscribe: functionsMocks.generatedAnswerStateSubscriber, + retry: functionsMocks.retry, })), buildSearchStatus: jest.fn(() => ({ - subscribe: jest.fn((callback) => callback()), - state: {}, + state: searchStatusState, + subscribe: functionsMocks.searchStatusStateSubscriber, })), + generatedAnswerStateSubscriber: jest.fn((cb) => { + cb(); + return functionsMocks.generatedAnswerStateUnsubscriber; + }), + searchStatusStateSubscriber: jest.fn((cb) => { + cb(); + return functionsMocks.searchStatusStateUnsubscriber; + }), + generatedAnswerStateUnsubscriber: jest.fn(), + searchStatusStateUnsubscriber: jest.fn(), + retry: jest.fn(), }; -function prepareHeadlessState() { - // @ts-ignore - mockHeadlessLoader.getHeadlessBundle = () => { - return { - buildGeneratedAnswer: functionsMocks.buildGeneratedAnswer, - buildSearchStatus: functionsMocks.buildSearchStatus, - }; - }; -} - // Helper function to wait until the microtask queue is empty. function flushPromises() { // eslint-disable-next-line @lwc/lwc/no-async-operation @@ -51,6 +101,17 @@ const exampleEngine = { id: 'dummy engine', }; let isInitialized = false; +const maximumAnswerHeight = 250; + +function prepareHeadlessState() { + // @ts-ignore + mockHeadlessLoader.getHeadlessBundle = () => { + return { + buildGeneratedAnswer: functionsMocks.buildGeneratedAnswer, + buildSearchStatus: functionsMocks.buildSearchStatus, + }; + }; +} function mockSuccessfulHeadlessInitialization() { // @ts-ignore @@ -62,6 +123,15 @@ function mockSuccessfulHeadlessInitialization() { }; } +function mockErroneousHeadlessInitialization() { + // @ts-ignore + mockHeadlessLoader.initializeWithHeadless = (element) => { + if (element instanceof QuanticGeneratedAnswer) { + element.setInitializationError(); + } + }; +} + function cleanup() { // The jsdom instance is shared across test cases in a single file so reset the DOM while (document.body.firstChild) { @@ -72,19 +142,125 @@ function cleanup() { } describe('c-quantic-generated-answer', () => { - beforeAll(() => { - mockSuccessfulHeadlessInitialization(); - }); - afterEach(() => { cleanup(); }); + 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('when an RGA retryable error occurs', () => { + beforeEach(() => { + searchStatusState = {...initialSearchStatusState, hasError: false}; + generatedAnswerState = { + ...initialGeneratedAnswerState, + error: { + isRetryable: true, + }, + }; + mockSuccessfulHeadlessInitialization(); + prepareHeadlessState(); + }); + + afterAll(() => { + generatedAnswerState = initialGeneratedAnswerState; + searchStatusState = initialSearchStatusState; + }); + + it('should display retry prompt', async () => { + const element = createTestComponent(); + await flushPromises(); + + const generatedAnswerCard = element.shadowRoot.querySelector( + selectors.generatedAnswerCard + ); + const generatedAnswerRetryButton = element.shadowRoot.querySelector( + selectors.generatedAnswerRetryButton + ); + + expect(generatedAnswerCard).not.toBeNull(); + expect(generatedAnswerRetryButton).not.toBeNull(); + }); + + describe('when the retry button is clicked', () => { + it('should call the retry method of the generated answer controller controller', async () => { + const element = createTestComponent(); + await flushPromises(); + + const generatedAnswerRetryButton = element.shadowRoot.querySelector( + selectors.generatedAnswerRetryButton + ); + + expect(generatedAnswerRetryButton).not.toBeNull(); + generatedAnswerRetryButton.click(); + expect(functionsMocks.retry).toHaveBeenCalledTimes(1); + }); + }); + }); + describe('controller initialization', () => { + beforeEach(() => { + mockSuccessfulHeadlessInitialization(); + prepareHeadlessState(); + }); + + it('should build the generated answer and search status controllers with the proper parameters', async () => { + createTestComponent(); + await flushPromises(); + + expect(functionsMocks.buildGeneratedAnswer).toHaveBeenCalledTimes(1); + expect(functionsMocks.buildGeneratedAnswer).toHaveBeenCalledWith( + exampleEngine, + { + initialState: { + isVisible: true, + responseFormat: { + contentFormat: ['text/markdown', 'text/plain'], + }, + }, + fieldsToIncludeInCitations: + defaultOptions.fieldsToIncludeInCitations.split(','), + } + ); + expect(functionsMocks.buildSearchStatus).toHaveBeenCalledTimes(1); + expect(functionsMocks.buildSearchStatus).toHaveBeenCalledWith( + exampleEngine + ); + }); + + it('should subscribe to the headless generated answer and search status state changes', async () => { + createTestComponent(); + await flushPromises(); + + expect( + functionsMocks.generatedAnswerStateSubscriber + ).toHaveBeenCalledTimes(1); + expect(functionsMocks.searchStatusStateSubscriber).toHaveBeenCalledTimes( + 1 + ); + }); + describe('when the answer configuration id property is passed to the component', () => { it('should initialize the controller with the correct answer configuration id value', async () => { const exampleAnswerConfigValue = 'exampleAnswerConfig'; - createTestComponent({answerConfigurationId: exampleAnswerConfigValue}); + createTestComponent({ + ...defaultOptions, + answerConfigurationId: exampleAnswerConfigValue, + }); await flushPromises(); expect(functionsMocks.buildGeneratedAnswer).toHaveBeenCalledTimes(1); @@ -112,4 +288,345 @@ describe('c-quantic-generated-answer', () => { }); }); }); + + describe('the rendering of the generated answer', () => { + describe('when the answer is streaming', () => { + const exampleAnswer = 'answer being generated'; + const exampleAnswerContentFormat = 'text/markdown'; + beforeEach(() => { + generatedAnswerState = { + ...initialGeneratedAnswerState, + isStreaming: true, + answer: exampleAnswer, + answerContentFormat: exampleAnswerContentFormat, + }; + mockSuccessfulHeadlessInitialization(); + prepareHeadlessState(); + }); + + afterAll(() => { + generatedAnswerState = initialGeneratedAnswerState; + }); + + it('should display the generated answer card', async () => { + const element = createTestComponent(); + await flushPromises(); + + const generatedAnswerCard = element.shadowRoot.querySelector( + selectors.generatedAnswerCard + ); + const generatedAnswerBadge = element.shadowRoot.querySelector( + selectors.generatedAnswerBadge + ); + + expect(generatedAnswerCard).not.toBeNull(); + expect(generatedAnswerBadge).not.toBeNull(); + }); + + describe('when the property withToggle is set to true', () => { + it('should display the generated answer toggle button', async () => { + const element = createTestComponent({ + ...defaultOptions, + withToggle: true, + }); + await flushPromises(); + + const generatedAnswerToggleButton = element.shadowRoot.querySelector( + selectors.generatedAnswerToggleButton + ); + + expect(generatedAnswerToggleButton).not.toBeNull(); + }); + }); + + describe('when the property collapsible is set to true', () => { + describe('when the answer is shorter than the maximum answer height', () => { + beforeEach(() => { + mockAnswerHeight = maximumAnswerHeight - 100; + }); + + it('should not display the generating answer message', async () => { + const element = createTestComponent({ + ...defaultOptions, + collapsible: true, + }); + await flushPromises(); + + const generatingMessageWhenAnswerCollapsed = + element.shadowRoot.querySelector( + selectors.generatingMessageWhenAnswerCollapsed + ); + + expect(generatingMessageWhenAnswerCollapsed).toBeNull(); + }); + }); + + describe('when the answer is longer than the maximum answer height', () => { + beforeEach(() => { + mockAnswerHeight = maximumAnswerHeight + 100; + }); + + it('should display the generating answer message', async () => { + const element = createTestComponent({ + ...defaultOptions, + collapsible: true, + }); + await flushPromises(); + + const generatingMessageWhenAnswerCollapsed = + element.shadowRoot.querySelector( + selectors.generatingMessageWhenAnswerCollapsed + ); + + expect(generatingMessageWhenAnswerCollapsed).not.toBeNull(); + }); + + it('should not display the generated answer collapse toggle', async () => { + const element = createTestComponent({ + ...defaultOptions, + collapsible: true, + }); + await flushPromises(); + + const generatedAnswerCollapseToggle = + element.shadowRoot.querySelector( + selectors.generatedAnswerCollapseToggle + ); + + expect(generatedAnswerCollapseToggle).toBeNull(); + }); + }); + }); + + it('should display the generated answer content', async () => { + const element = createTestComponent(); + await flushPromises(); + + const generatedAnswerContent = element.shadowRoot.querySelector( + selectors.generatedAnswerContent + ); + + expect(generatedAnswerContent).not.toBeNull(); + expect(generatedAnswerContent.isStreaming).toBe(true); + expect(generatedAnswerContent.answer).toBe(exampleAnswer); + expect(generatedAnswerContent.answerContentFormat).toBe( + exampleAnswerContentFormat + ); + }); + + it('should not display the generated answer actions', async () => { + const element = createTestComponent(); + await flushPromises(); + + const generatedAnswerActions = element.shadowRoot.querySelector( + selectors.generatedAnswerActions + ); + + expect(generatedAnswerActions).toBeNull(); + }); + + it('should not display the generated answer disclaimer', async () => { + const element = createTestComponent(); + await flushPromises(); + + const generatedAnswerDisclaimer = element.shadowRoot.querySelector( + selectors.generatedAnswerDisclaimer + ); + + expect(generatedAnswerDisclaimer).toBeNull(); + }); + }); + + describe('when the answer is ready', () => { + const exampleAnswer = 'answer generated successfully'; + const exampleAnswerContentFormat = 'text/markdown'; + beforeEach(() => { + generatedAnswerState = { + ...initialGeneratedAnswerState, + isStreaming: false, + answer: exampleAnswer, + answerContentFormat: exampleAnswerContentFormat, + }; + mockSuccessfulHeadlessInitialization(); + prepareHeadlessState(); + }); + + afterAll(() => { + generatedAnswerState = initialGeneratedAnswerState; + }); + + it('should display the generated answer card', async () => { + const element = createTestComponent(); + await flushPromises(); + + const generatedAnswerCard = element.shadowRoot.querySelector( + selectors.generatedAnswerCard + ); + const generatedAnswerBadge = element.shadowRoot.querySelector( + selectors.generatedAnswerBadge + ); + + expect(generatedAnswerCard).not.toBeNull(); + expect(generatedAnswerBadge).not.toBeNull(); + }); + + describe('when the property withToggle is set to true', () => { + it('should display the generated answer toggle button', async () => { + const element = createTestComponent({ + ...defaultOptions, + withToggle: true, + }); + await flushPromises(); + + const generatedAnswerToggleButton = element.shadowRoot.querySelector( + selectors.generatedAnswerToggleButton + ); + + expect(generatedAnswerToggleButton).not.toBeNull(); + }); + }); + + describe('when the property collapsible is set to true', () => { + describe('when the answer is shorter than the maximum answer height', () => { + beforeEach(() => { + mockAnswerHeight = maximumAnswerHeight - 100; + }); + + it('should not display the generating answer message', async () => { + const element = createTestComponent({ + ...defaultOptions, + collapsible: true, + }); + await flushPromises(); + + const generatingMessageWhenAnswerCollapsed = + element.shadowRoot.querySelector( + selectors.generatingMessageWhenAnswerCollapsed + ); + + expect(generatingMessageWhenAnswerCollapsed).toBeNull(); + }); + + it('should not display the generated answer collapse toggle', async () => { + const element = createTestComponent({ + ...defaultOptions, + collapsible: true, + }); + await flushPromises(); + + const generatedAnswerCollapseToggle = + element.shadowRoot.querySelector( + selectors.generatedAnswerCollapseToggle + ); + + expect(generatedAnswerCollapseToggle).toBeNull(); + }); + }); + + describe('when the answer is longer than the maximum answer height', () => { + beforeEach(() => { + mockAnswerHeight = maximumAnswerHeight + 100; + }); + + it('should not display the generating answer message', async () => { + const element = createTestComponent({ + ...defaultOptions, + collapsible: true, + }); + await flushPromises(); + + const generatingMessageWhenAnswerCollapsed = + element.shadowRoot.querySelector( + selectors.generatingMessageWhenAnswerCollapsed + ); + + expect(generatingMessageWhenAnswerCollapsed).toBeNull(); + }); + + it('should display the generated answer collapse toggle', async () => { + const element = createTestComponent({ + ...defaultOptions, + collapsible: true, + }); + await flushPromises(); + + const generatedAnswerCollapseToggle = + element.shadowRoot.querySelector( + selectors.generatedAnswerCollapseToggle + ); + + expect(generatedAnswerCollapseToggle).not.toBeNull(); + }); + }); + }); + + it('should display the generated answer content', async () => { + const element = createTestComponent(); + await flushPromises(); + + const generatedAnswerContent = element.shadowRoot.querySelector( + selectors.generatedAnswerContent + ); + + expect(generatedAnswerContent).not.toBeNull(); + expect(generatedAnswerContent.isStreaming).toBe(false); + expect(generatedAnswerContent.answer).toBe(exampleAnswer); + expect(generatedAnswerContent.answerContentFormat).toBe( + exampleAnswerContentFormat + ); + }); + + it('should display the generated answer actions', async () => { + const element = createTestComponent(); + await flushPromises(); + + const generatedAnswerActions = element.shadowRoot.querySelector( + selectors.generatedAnswerActions + ); + + expect(generatedAnswerActions).not.toBeNull(); + }); + + it('should not display the generated answer disclaimer', async () => { + const element = createTestComponent(); + await flushPromises(); + + const generatedAnswerDisclaimer = element.shadowRoot.querySelector( + selectors.generatedAnswerDisclaimer + ); + + expect(generatedAnswerDisclaimer).not.toBeNull(); + }); + }); + + describe('when the answer is empty', () => { + const exampleEmptyAnswer = ''; + const exampleAnswerContentFormat = 'text/markdown'; + beforeEach(() => { + generatedAnswerState = { + ...initialGeneratedAnswerState, + isStreaming: false, + answer: exampleEmptyAnswer, + answerContentFormat: exampleAnswerContentFormat, + }; + mockSuccessfulHeadlessInitialization(); + prepareHeadlessState(); + }); + + afterAll(() => { + generatedAnswerState = initialGeneratedAnswerState; + }); + + it('should not display the generated answer card', async () => { + const element = createTestComponent(); + await flushPromises(); + + const generatedAnswerCard = element.shadowRoot.querySelector( + selectors.generatedAnswerCard + ); + + expect(generatedAnswerCard).toBeNull(); + }); + }); + }); }); diff --git a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/e2e/data.ts b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/e2e/data.ts new file mode 100644 index 00000000000..4cb61a64aaa --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/e2e/data.ts @@ -0,0 +1,56 @@ +const exampleStreamId = '123'; +const genQaMarkdownTextPayload = { + payloadType: 'genqa.messageType', + payload: JSON.stringify({ + textDelta: + 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.', + }), + finishReason: 'COMPLETED', +}; +const genQaMarkdownTypePayload = { + payloadType: 'genqa.headerMessageType', + payload: JSON.stringify({ + contentFormat: 'text/markdown', + }), +}; +const genQaStreamEndPayload = { + payloadType: 'genqa.endOfStreamType', + payload: JSON.stringify({ + answerGenerated: true, + }), +}; +const exampleCitation = { + id: 'some-id-1', + title: 'Some Title 1', + uri: 'https://www.coveo.com', + permanentid: 'some-permanent-id-1', + clickUri: '#', + text: 'example text 1', + source: 'Some source 1', +}; +const exampleCitations = [exampleCitation]; +const genQaCitationPayload = { + payloadType: 'genqa.citationsType', + payload: JSON.stringify({ + citations: exampleCitations, + }), +}; + +export type GenQaData = { + streamId: string; + streams: Array<{payloadType: string; payload: string; finishReason?: string}>; + citations: Array>; +}; + +const genQaData: GenQaData = { + streamId: exampleStreamId, + streams: [ + genQaMarkdownTypePayload, + genQaMarkdownTextPayload, + genQaCitationPayload, + genQaStreamEndPayload, + ], + citations: exampleCitations, +}; + +export default genQaData; diff --git a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/e2e/fixture.ts b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/e2e/fixture.ts new file mode 100644 index 00000000000..6d004909d89 --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/e2e/fixture.ts @@ -0,0 +1,87 @@ +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'; +import {GeneratedAnswerObject} from './pageObject'; +import genQaData from './data'; +import type {GenQaData} from './data'; + +const pageUrl = 's/quantic-generated-answer'; + +interface GeneratedAnswerOptions { + fieldsToIncludeInCitations: string; + collapsible: boolean; + withToggle: boolean; + useCase: string; +} + +type QuanticGeneratedAnswerE2ESearchFixtures = { + genQaData: GenQaData; + generatedAnswer: GeneratedAnswerObject; + search: SearchObject; + options: Partial; +}; + +type QuanticGeneratedAnswerE2EInsightFixtures = + QuanticGeneratedAnswerE2ESearchFixtures & { + insightSetup: InsightSetupObject; + }; + +export const testSearch = + quanticBase.extend({ + genQaData, + options: {}, + search: async ({page}, use) => { + await use(new SearchObject(page, searchRequestRegex)); + }, + generatedAnswer: async ( + {page, options, configuration, search, genQaData: data}, + use + ) => { + const generatedAnswerObject = new GeneratedAnswerObject( + page, + data.streamId + ); + await page.goto(pageUrl); + await search.mockSearchWithGenerativeQuestionAnsweringId(data.streamId); + await generatedAnswerObject.mockStreamResponse(data.streams); + await configuration.configure(options); + await search.waitForSearchResponse(); + await use(generatedAnswerObject); + }, + }); + +export const testInsight = + quanticBase.extend({ + genQaData, + options: {}, + search: async ({page}, use) => { + await use(new SearchObject(page, insightSearchRequestRegex)); + }, + insightSetup: async ({page}, use) => { + await use(new InsightSetupObject(page)); + }, + generatedAnswer: async ( + {page, options, search, configuration, insightSetup, genQaData: data}, + use + ) => { + const generatedAnswerObject = new GeneratedAnswerObject( + page, + data.streamId + ); + await page.goto(pageUrl); + await search.mockSearchWithGenerativeQuestionAnsweringId(data.streamId); + await generatedAnswerObject.mockStreamResponse(data.streams); + configuration.configure({...options, useCase: useCaseEnum.insight}); + await insightSetup.waitForInsightInterfaceInitialization(); + await search.performSearch(); + await search.waitForSearchResponse(); + await use(generatedAnswerObject); + }, + }); + +export {expect} from '@playwright/test'; diff --git a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/e2e/pageObject.ts b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/e2e/pageObject.ts new file mode 100644 index 00000000000..0e303abad5c --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/e2e/pageObject.ts @@ -0,0 +1,279 @@ +import type {Locator, Page, Request} from '@playwright/test'; +import { + isUaClickEvent, + isUaCustomEvent, +} from '../../../../../../playwright/utils/requests'; + +const minimumCitationTooltipDisplayDurationMs = 1500; + +export class GeneratedAnswerObject { + constructor( + public page: Page, + public streamId: string + ) { + this.page = page; + this.streamId = streamId; + } + + get likeButton(): Locator { + return this.page.getByRole('button', {name: 'This answer was helpful'}); + } + + get dislikeButton(): Locator { + return this.page.getByRole('button', {name: 'This answer was not helpful'}); + } + + get copyToClipboardButton(): Locator { + return this.page.getByRole('button', {name: 'Copy'}); + } + + get toggleButton(): Locator { + return this.page.getByTestId('generated-answer__toggle-button'); + } + + questionContainer(questionId: string): Locator { + return this.page.getByTestId(questionId); + } + + answerOption(questionId: string, answerValue: string): Locator { + return this.questionContainer(questionId).locator('.slds-radio_button', { + hasText: new RegExp(`^${answerValue}$`), + }); + } + + get feedbackDocumentUrlInput(): Locator { + return this.page.locator( + '.feedback-modal-qna__body input[name="documentUrl"]' + ); + } + + get feedbackDetailsInput(): Locator { + return this.page + .locator('.feedback-modal-qna__body [data-name="details"]') + .getByRole('textbox'); + } + + get submitFeedbackButton(): Locator { + return this.page.getByRole('button', {name: /Send feedback/i}); + } + + get citationLink(): Locator { + return this.page + .getByTestId('generated-answer__citations') + .locator('.citation__link'); + } + + async hoverOverCitation(index: number): Promise { + // waiting 500ms to allow the component to render completely, cause any re-rendering abort the hover action. + await this.page.waitForTimeout(500); + await this.citationLink.nth(index).hover(); + await this.page.waitForTimeout(minimumCitationTooltipDisplayDurationMs); + await this.page.mouse.move(0, 0); + } + + async clickOnCitation(index: number): Promise { + await this.citationLink.nth(index).click(); + } + + async typeInFeedbackDocumentUrlInput(text: string): Promise { + await this.feedbackDocumentUrlInput.fill(text); + } + + async typeInFeedbackDetailsInput(text: string): Promise { + await this.feedbackDetailsInput.fill(text); + } + + async clickSubmitFeedbackButton(): Promise { + await this.submitFeedbackButton.click(); + } + + async fillFeedbackForm(answers: Record): Promise { + for (const [questionId, answerValue] of Object.entries(answers)) { + const option = this.answerOption(questionId, answerValue); + // eslint-disable-next-line no-await-in-loop + await option.click(); + } + } + + async clickLikeButton(): Promise { + await this.likeButton.click(); + } + + async clickDislikeButton(): Promise { + await this.dislikeButton.click(); + } + + async clickCopyToClipboardButton(): Promise { + await this.copyToClipboardButton.click(); + } + + async clickToggleButton(): Promise { + await this.toggleButton.click(); + } + + async waitForStreamEndUaAnalytics(): Promise { + return this.waitForGeneratedAnswerCustomUaAnalytics( + 'generatedAnswerStreamEnd' + ); + } + + async waitForLikeGeneratedAnswerUaAnalytics(): Promise { + return this.waitForGeneratedAnswerCustomUaAnalytics('likeGeneratedAnswer'); + } + + async waitForDislikeGeneratedAnswerUaAnalytics(): Promise { + return this.waitForGeneratedAnswerCustomUaAnalytics( + 'dislikeGeneratedAnswer' + ); + } + + async waitForCopyToClipboardUaAnalytics(): Promise { + return this.waitForGeneratedAnswerCustomUaAnalytics( + 'generatedAnswerCopyToClipboard' + ); + } + + async waitForShowAnswersUaAnalytics(): Promise { + return this.waitForGeneratedAnswerCustomUaAnalytics( + 'generatedAnswerShowAnswers' + ); + } + + async waitForHideAnswersUaAnalytics(): Promise { + return this.waitForGeneratedAnswerCustomUaAnalytics( + 'generatedAnswerHideAnswers' + ); + } + + async waitForSourceHoverUaAnalytics( + expectedFields: Record + ): Promise { + return this.waitForGeneratedAnswerCustomUaAnalytics( + 'generatedAnswerSourceHover', + (customData: Record) => { + return Object.keys(expectedFields).every( + (key) => customData?.[key] === expectedFields[key] + ); + } + ); + } + + async waitForFeedbackSubmitUaAnalytics( + expectedFields: Record + ): Promise { + return this.waitForGeneratedAnswerCustomUaAnalytics( + 'generatedAnswerFeedbackSubmitV2', + (customData: Record) => { + return Object.keys(expectedFields).every( + (key) => customData?.[key] === expectedFields[key] + ); + } + ); + } + + async waitForCitationClickUaAnalytics( + expectedFields: Record, + expectedCustomFields: Record + ): Promise { + return this.waitForGeneratedAnswerClickUaAnalytics( + 'generatedAnswerCitationClick', + (data: Record, customData: Record) => { + return ( + Object.keys(expectedFields).every( + (key) => data?.[key] === expectedFields[key] + ) && + Object.keys(expectedCustomFields).every( + (key) => customData?.[key] === expectedCustomFields[key] + ) + ); + } + ); + } + + async waitForGeneratedAnswerClickUaAnalytics( + actionCause: string, + customChecker?: Function + ): Promise { + const uaRequest = this.page.waitForRequest((request) => { + if (isUaClickEvent(request)) { + const requestBody = request.postDataJSON?.(); + const requestData = JSON.parse(requestBody.clickEvent); + + const expectedFields: Record = { + actionCause, + }; + + const matchesExpectedFields = Object.keys(expectedFields).every( + (key) => requestData?.[key] === expectedFields[key] + ); + + const customData = requestData?.customData; + + const matchesGenerativeId = + customData?.generativeQuestionAnsweringId === this.streamId; + + return ( + matchesExpectedFields && + matchesGenerativeId && + (customChecker ? customChecker(requestData, customData) : true) + ); + } + return false; + }); + return uaRequest; + } + + async waitForGeneratedAnswerCustomUaAnalytics( + eventValue: string, + customChecker?: Function + ): Promise { + const uaRequest = this.page.waitForRequest((request) => { + if (isUaCustomEvent(request)) { + const requestBody = request.postDataJSON?.(); + const expectedFields: Record = { + eventType: 'generatedAnswer', + eventValue: eventValue, + }; + + const matchesExpectedFields = Object.keys(expectedFields).every( + (key) => requestBody?.[key] === expectedFields[key] + ); + + const customData = requestBody?.customData; + + const matchesGenerativeId = + customData?.generativeQuestionAnsweringId === this.streamId; + + return ( + matchesExpectedFields && + matchesGenerativeId && + (customChecker ? customChecker(customData) : true) + ); + } + return false; + }); + return uaRequest; + } + + async mockStreamResponse( + body: Array<{payloadType: string; payload: string; finishReason?: string}> + ) { + await this.page.route( + `**/machinelearning/streaming/${this.streamId}`, + (route) => { + let bodyText = ''; + body.forEach((data) => { + bodyText += `data: ${JSON.stringify(data)} \n\n`; + }); + + route.fulfill({ + status: 200, + body: bodyText, + headers: { + 'content-type': 'text/event-stream', + }, + }); + } + ); + } +} diff --git a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/e2e/quanticGeneratedAnswer.e2e.ts b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/e2e/quanticGeneratedAnswer.e2e.ts new file mode 100644 index 00000000000..929658d2e0c --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/e2e/quanticGeneratedAnswer.e2e.ts @@ -0,0 +1,178 @@ +import {testSearch, testInsight} from './fixture'; +import {useCaseTestCases} from '../../../../../../playwright/utils/useCase'; +import genQaData from './data'; + +const fixtures = { + search: testSearch, + insight: testInsight, +}; + +useCaseTestCases.forEach((useCase) => { + let test = fixtures[useCase.value]; + + test.describe(`quantic generated answer ${useCase.label}`, () => { + test.use({ + genQaData, + }); + + test.describe('when the answer has been generated', () => { + test('should send a stream end analytics event', async ({ + generatedAnswer, + }) => { + await generatedAnswer.waitForStreamEndUaAnalytics(); + }); + }); + + test.describe('when providing positive feedback', () => { + test('should send positive feedback analytics containing all details', async ({ + generatedAnswer, + }) => { + const likeAnalyticRequestPromise = + generatedAnswer.waitForLikeGeneratedAnswerUaAnalytics(); + await generatedAnswer.clickLikeButton(); + await likeAnalyticRequestPromise; + + const exampleDocumentUrl = 'https://www.coveo.com/'; + const exampleDetails = 'example details...'; + await generatedAnswer.fillFeedbackForm({ + correctTopic: 'Yes', + hallucinationFree: 'Yes', + documented: 'Not sure', + readable: 'Yes', + }); + await generatedAnswer.typeInFeedbackDocumentUrlInput( + exampleDocumentUrl + ); + await generatedAnswer.typeInFeedbackDetailsInput(exampleDetails); + + const feedbackAnalyticRequestPromise = + generatedAnswer.waitForFeedbackSubmitUaAnalytics({ + correctTopic: 'yes', + hallucinationFree: 'yes', + documented: 'unknown', + readable: 'yes', + documentUrl: exampleDocumentUrl, + details: exampleDetails, + helpful: true, + }); + await generatedAnswer.clickSubmitFeedbackButton(); + await feedbackAnalyticRequestPromise; + }); + }); + + test.describe('when providing negative feedback', () => { + test('should send negative feedback analytics containing all details', async ({ + generatedAnswer, + }) => { + const dislikeAnalyticRequestPromise = + generatedAnswer.waitForDislikeGeneratedAnswerUaAnalytics(); + await generatedAnswer.clickDislikeButton(); + await dislikeAnalyticRequestPromise; + + const exampleDocumentUrl = 'https://www.coveo.com/'; + const exampleDetails = 'example details...'; + await generatedAnswer.fillFeedbackForm({ + correctTopic: 'No', + hallucinationFree: 'Not sure', + documented: 'No', + readable: 'No', + }); + await generatedAnswer.typeInFeedbackDocumentUrlInput( + exampleDocumentUrl + ); + await generatedAnswer.typeInFeedbackDetailsInput(exampleDetails); + + const feedbackAnalyticRequestPromise = + generatedAnswer.waitForFeedbackSubmitUaAnalytics({ + correctTopic: 'no', + hallucinationFree: 'unknown', + documented: 'no', + readable: 'no', + documentUrl: exampleDocumentUrl, + details: exampleDetails, + helpful: false, + }); + await generatedAnswer.clickSubmitFeedbackButton(); + await feedbackAnalyticRequestPromise; + }); + }); + + test.describe('when copying the generated answer to clipboard', () => { + test('should send a copy to clipboard analytics event', async ({ + generatedAnswer, + }) => { + const analyticRequestPromise = + generatedAnswer.waitForCopyToClipboardUaAnalytics(); + await generatedAnswer.clickCopyToClipboardButton(); + await analyticRequestPromise; + }); + }); + + test.describe('when the property withToggle is set to true', () => { + test.use({ + options: { + withToggle: true, + }, + }); + + test('should allow toggeling the generated OFF and ON and log analytics', async ({ + generatedAnswer, + }) => { + const hideAnswerAnalyticRequestPromise = + generatedAnswer.waitForHideAnswersUaAnalytics(); + await generatedAnswer.clickToggleButton(); + await hideAnswerAnalyticRequestPromise; + + const showAnswerAnalyticRequestPromise = + generatedAnswer.waitForShowAnswersUaAnalytics(); + await generatedAnswer.clickToggleButton(); + await showAnswerAnalyticRequestPromise; + }); + }); + + test.describe('when interacting with citations', () => { + test.describe('when hovering over a citation', () => { + test('should log citation hover analytics', async ({ + generatedAnswer, + }) => { + const citationIndex = 0; + const {id, permanentid} = genQaData.citations[citationIndex]; + const citationHoverAnalyticRequestPromise = + generatedAnswer.waitForSourceHoverUaAnalytics({ + citationId: id, + permanentId: permanentid, + }); + await generatedAnswer.hoverOverCitation(citationIndex); + await citationHoverAnalyticRequestPromise; + }); + }); + + test.describe('when clicking on a citation', () => { + test('should log citation click analytics', async ({ + generatedAnswer, + }) => { + const citationIndex = 0; + const {id, title, source, uri, clickUri, permanentid} = + genQaData.citations[citationIndex]; + const citationClickAnalyticRequestPromise = + generatedAnswer.waitForCitationClickUaAnalytics( + { + documentTitle: title, + sourceName: source, + documentPosition: citationIndex + 1, + documentUri: uri, + documentUrl: clickUri, + }, + { + citationId: id, + contentIDKey: 'permanentid', + contentIDValue: permanentid, + } + ); + await generatedAnswer.clickOnCitation(citationIndex); + await citationClickAnalyticRequestPromise; + }); + }); + }); + }); +}); diff --git a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.html b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.html index a5db411a0fe..c66a1726caa 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.html +++ b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.html @@ -1,14 +1,18 @@