diff --git a/.circleci/config.yml b/.circleci/config.yml index 61aa2dcc3..79dfe3ded 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -22,6 +22,10 @@ executors: - image: cimg/node:20.13.1 resource_class: medium working_directory: ~/project + ci-macos: + macos: + xcode: 15.0.0 # Use the closest available version of macOS (Sonoma equivalent may not be available yet) + working_directory: ~/project commands: attach_project: @@ -105,6 +109,30 @@ jobs: no_output_timeout: 15m name: Run test command: yarn test + # CI - Run snapshot tests + run-snapshot-test: + executor: ci-macos + steps: + - checkout + - run: + name: Enable Corepack + command: corepack enable + - run: + name: Install dependencies + command: | + git submodule update --init --recursive + yarn install --immutable + - run: + name: Install Playwright browsers + command: npx playwright install --with-deps + - run: + name: Run Playwright snapshot tests # refer to https://circleci.com/docs/collect-test-data/#playwright + command: PLAYWRIGHT_JUNIT_OUTPUT_NAME=results.xml yarn playwright test --config=playwright.config.ts + - store_test_results: + path: results.xml + - store_artifacts: + path: ~/project/playwright-report + destination: playwright-html-report # Publish - build self-service build: @@ -201,6 +229,9 @@ workflows: - run-test: requires: - prepare + ci-snapshot-test: + jobs: + - run-snapshot-test deploy_prod: when: << pipeline.parameters.run_deploy_prod >> diff --git a/.gitignore b/.gitignore index 72c6345ed..298947c8f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ dist-ssr *.njsproj *.sln *.sw? +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-1-chromium-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-1-chromium-darwin.png new file mode 100644 index 000000000..6e67e7a2c Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-1-chromium-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-1-firefox-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-1-firefox-darwin.png new file mode 100644 index 000000000..88d9399eb Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-1-firefox-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-2-chromium-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-2-chromium-darwin.png new file mode 100644 index 000000000..14d6407d8 Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-2-chromium-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-2-firefox-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-2-firefox-darwin.png new file mode 100644 index 000000000..06c15b33e Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-2-firefox-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-3-chromium-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-3-chromium-darwin.png new file mode 100644 index 000000000..a8cebdfdb Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-3-chromium-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-3-firefox-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-3-firefox-darwin.png new file mode 100644 index 000000000..bcdabe573 Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-3-firefox-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-4-chromium-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-4-chromium-darwin.png new file mode 100644 index 000000000..2135f5cfb Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-4-chromium-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-4-firefox-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-4-firefox-darwin.png new file mode 100644 index 000000000..3c8fe35a7 Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/100-4-firefox-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/101-1-chromium-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/101-1-chromium-darwin.png new file mode 100644 index 000000000..b043e0e6a Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/101-1-chromium-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/101-1-firefox-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/101-1-firefox-darwin.png new file mode 100644 index 000000000..bb3000e21 Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/101-1-firefox-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/102-1-chromium-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/102-1-chromium-darwin.png new file mode 100644 index 000000000..b2ea84bf9 Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/102-1-chromium-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/102-1-firefox-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/102-1-firefox-darwin.png new file mode 100644 index 000000000..71d2cda84 Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/102-1-firefox-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-1-chromium-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-1-chromium-darwin.png new file mode 100644 index 000000000..ea4370c63 Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-1-chromium-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-1-firefox-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-1-firefox-darwin.png new file mode 100644 index 000000000..45f2ced84 Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-1-firefox-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-2-chromium-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-2-chromium-darwin.png new file mode 100644 index 000000000..3f72d250e Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-2-chromium-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-2-firefox-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-2-firefox-darwin.png new file mode 100644 index 000000000..5548d7f37 Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-2-firefox-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-3-chromium-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-3-chromium-darwin.png new file mode 100644 index 000000000..863255f0b Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-3-chromium-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-3-firefox-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-3-firefox-darwin.png new file mode 100644 index 000000000..f9b5f03a0 Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-3-firefox-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-4-chromium-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-4-chromium-darwin.png new file mode 100644 index 000000000..edc231ed5 Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-4-chromium-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-4-firefox-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-4-firefox-darwin.png new file mode 100644 index 000000000..5982bff74 Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-4-firefox-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-5-chromium-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-5-chromium-darwin.png new file mode 100644 index 000000000..baf7cf38a Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-5-chromium-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-5-firefox-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-5-firefox-darwin.png new file mode 100644 index 000000000..998e2c498 Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-5-firefox-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-6-chromium-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-6-chromium-darwin.png new file mode 100644 index 000000000..51e981ebc Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-6-chromium-darwin.png differ diff --git a/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-6-firefox-darwin.png b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-6-firefox-darwin.png new file mode 100644 index 000000000..3b9c0c15e Binary files /dev/null and b/__visual_tests__/__snapshots__/workflow-tests.spec.ts/103-6-firefox-darwin.png differ diff --git a/__visual_tests__/const.ts b/__visual_tests__/const.ts new file mode 100644 index 000000000..e43b07633 --- /dev/null +++ b/__visual_tests__/const.ts @@ -0,0 +1,15 @@ +const appId = process.env.SNAPSHOT_TEST_APP_ID; +const botId = process.env.SNAPSHOT_TEST_BOT_ID; + +export const TEST_URL = `http://localhost:5173/chat-ai-widget/?app_id=${appId}&bot_id=${botId}&snapshot=true`; + +export const WidgetComponentIds = { + WIDGET: '#aichatbot-widget-window', + WIDGET_BUTTON: '#aichatbot-widget-button', + MESSAGE_INPUT: '#sendbird-message-input-text-field', + SUGGESTED_REPLIES_OPTIONS: '.sendbird-suggested-replies__option', + BUTTON: 'button.sendbird-button--primary', + INPUT: '.sendbird-input__input', + CHIPS_CONTAINER: '.sendbird-form-chip__container', + FORM: '#aichatbot-widget-form', +}; diff --git a/__visual_tests__/utils.ts b/__visual_tests__/utils.ts new file mode 100644 index 000000000..6d6634335 --- /dev/null +++ b/__visual_tests__/utils.ts @@ -0,0 +1,31 @@ +import { expect, Page } from '@playwright/test'; + +import { WidgetComponentIds } from './const'; + +export async function assertScreenshot(page: Page, screenshotName: string, browserName: string) { + const name = `${screenshotName}.${browserName}.${process.platform}.png`; // Include the browser and OS architecture info in the filename + await expect(page.locator(WidgetComponentIds.WIDGET)).toHaveScreenshot(name, { + omitBackground: false, + maxDiffPixelRatio: 0.01, // Need this because Sendbird logo is slightly differently rendered in CI. + }); +} + +export async function loadWidget(page: Page) { + await page.click(WidgetComponentIds.WIDGET_BUTTON); + // NOTE: below fails sometimes in CI. + const widgetWindow = page.locator(WidgetComponentIds.SUGGESTED_REPLIES_OPTIONS); + await widgetWindow.waitFor({ state: 'visible' }); + // await page.waitForTimeout(3000); +} + +export async function sendTextMessage(page: Page, text: string, waitTime = 1000) { + const input = page.locator(WidgetComponentIds.MESSAGE_INPUT); + await input.fill(text); + await input.press('Enter'); + await page.waitForTimeout(waitTime); +} + +export async function clickNthChip(page: Page, nth: number) { + const chipContainer = page.locator(WidgetComponentIds.CHIPS_CONTAINER); + await chipContainer.locator(':scope > *').nth(nth).click(); +} diff --git a/__visual_tests__/workflow-tests.spec.ts b/__visual_tests__/workflow-tests.spec.ts new file mode 100644 index 000000000..ecbcfabda --- /dev/null +++ b/__visual_tests__/workflow-tests.spec.ts @@ -0,0 +1,129 @@ +import { test } from '@playwright/test'; + +import { TEST_URL, WidgetComponentIds } from './const'; +import { assertScreenshot, clickNthChip, loadWidget, sendTextMessage } from './utils'; + +test.beforeEach(async ({ page }) => { + await page.goto(TEST_URL); + const widgetWindow = page.locator(WidgetComponentIds.WIDGET_BUTTON); + await widgetWindow.waitFor({ state: 'visible' }); +}); + +/** + * 100 + * Workflow - Form message + * Steps: + * 1. Send the trigger message: "Give me a food order form" + * 2. Submit form without filling the required fields. + * 3. Submit form with at least one invalid value. + * 4. Submit form with valid values. + */ +test('100', async ({ page, browserName }) => { + await loadWidget(page); + + // 1 + await sendTextMessage(page, 'Give me a food order form', 0); + const widgetWindow = page.locator(WidgetComponentIds.FORM); + await widgetWindow.waitFor({ state: 'visible' }); + await assertScreenshot(page, '100-1', browserName); + + // 2 + let submitButton = page.locator(WidgetComponentIds.BUTTON); + await submitButton.click(); + await assertScreenshot(page, '100-2', browserName); + + // 3 + const inputs = page.locator(WidgetComponentIds.INPUT); + await inputs.nth(0).fill('guy ordering food'); + await inputs.nth(2).fill('not a number'); + await inputs.nth(3).fill('not.a.valid.email.com'); + await inputs.nth(4).fill('123_456_7890'); + await clickNthChip(page, 4); + submitButton = page.locator(WidgetComponentIds.BUTTON); + await page.waitForTimeout(1000); + await assertScreenshot(page, '100-3', browserName); + + // 4 + await inputs.nth(2).fill('2'); + await inputs.nth(3).fill('guy.ordering.food@food.com'); + await inputs.nth(4).fill('123-456-7890'); + await submitButton.click(); + await page.waitForTimeout(1000); + await assertScreenshot(page, '100-4', browserName); +}); + +/** + * 101 + * Workflow - Function calls: user message + * Steps: + * 1. Send the trigger message: "Tell me about one cat breed" + */ +test('101', async ({ page, browserName }) => { + await loadWidget(page); + // 1 + await sendTextMessage(page, 'Tell me about one cat breed', 2000); + await assertScreenshot(page, '101-1', browserName); +}); + +/** + * 102 + * Workflow - File message + * Steps: + * 1. Send the trigger message: "Give me a travel agency poster" + */ +test('102', async ({ page, browserName }) => { + await loadWidget(page); + // 1 + await sendTextMessage(page, 'Give me a travel agency poster', 5000); + await assertScreenshot(page, '102-1', browserName); +}); + +/** + * 103 + * Workflow - Suggested replies with 'Back' enabled + * Steps: + * 1. Send the trigger message: "Suggested replies" + * 2. Click "Text" + * 3. Click "Back" + * 4. Click "File" + * 5. Click "Back" + * 6. Click "Link to workflow: form message" + */ +test('103', async ({ page, browserName }) => { + await loadWidget(page); + // 1 + await sendTextMessage(page, 'Suggested replies', 2000); + await assertScreenshot(page, '103-1', browserName); + + // 2 + let options = page.locator(WidgetComponentIds.SUGGESTED_REPLIES_OPTIONS); + await options.nth(0).click(); + await page.waitForTimeout(1000); + await assertScreenshot(page, '103-2', browserName); + + // 3 + options = page.locator(WidgetComponentIds.SUGGESTED_REPLIES_OPTIONS); + await options.nth(0).click(); + await page.waitForTimeout(1000); + await assertScreenshot(page, '103-3', browserName); + + // 4 + options = page.locator(WidgetComponentIds.SUGGESTED_REPLIES_OPTIONS); + await options.nth(1).click(); + await page.waitForTimeout(4000); // Time takes long for file message to be rendered and then scrolled to bottom in CI browsers. + await assertScreenshot(page, '103-4', browserName); + + // 5 + options = page.locator(WidgetComponentIds.SUGGESTED_REPLIES_OPTIONS); + await options.nth(0).click(); + options = page.locator(WidgetComponentIds.SUGGESTED_REPLIES_OPTIONS); + // Expecting three options. + await options.nth(2).waitFor({ state: 'visible' }); + await assertScreenshot(page, '103-5', browserName); + + // 6 + options = page.locator(WidgetComponentIds.SUGGESTED_REPLIES_OPTIONS); + await options.nth(2).click(); + await page.waitForTimeout(1000); + await assertScreenshot(page, '103-6', browserName); +}); diff --git a/package.json b/package.json index be3452dd7..55069d97c 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,9 @@ "build:npm": "node scripts/prebuild.mjs && yarn build", "build:pages": "rm -rf ./dist && tsc-silent -p './tsconfig.json' --suppress @ && vite build --config vite.config.pages.ts", "format": "yarn prettier:fix && yarn lint:fix", - "format:check": "yarn prettier src --check && yarn eslint src", - "lint:fix": "yarn eslint src --fix", - "prettier:fix": "yarn prettier src --write", + "format:check": "yarn prettier src __visual_tests__ --check && yarn eslint src __visual_tests__", + "lint:fix": "yarn eslint src __visual_tests__ --fix", + "prettier:fix": "yarn prettier src __visual_tests__ --write", "preview": "vite preview", "test": "vitest run" }, @@ -37,7 +37,9 @@ "@linaria/atomic": "^6.2.0", "@linaria/core": "^6.2.0", "@linaria/react": "^6.2.1", + "@playwright/test": "^1.48.1", "@types/dompurify": "^3.0.5", + "@types/node": "^22.7.9", "@types/react": "^18.0.37", "@types/react-dom": "^18.0.11", "@types/styled-components": "^5.1.26", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..3dba5189a --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,83 @@ +import {defineConfig, devices} from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './__visual_tests__', + snapshotPathTemplate: '{testDir}/__snapshots__/{testFilePath}/{arg}{ext}', // Refer to: https://playwright.dev/docs/next/api/class-testproject#test-project-snapshot-path-template + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, // process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: undefined, // process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ['junit', { outputFile: 'results.xml' }], + ['html'] + ], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, // Note we cannot use ...devices['Desktop Chrome'] because the name varies between devices and in CircleCI environment. CI test will fail because of this. + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, // Note we cannot use ...devices['Desktop Firefox'] because the name varies between devices and in CircleCI environment. CI test will fail because of this. + }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'yarn dev', + url: 'http://localhost:5173/chat-ai-widget/', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/src/components/BotMessageWithBodyInput.tsx b/src/components/BotMessageWithBodyInput.tsx index 6b72aa6a3..4a396247e 100644 --- a/src/components/BotMessageWithBodyInput.tsx +++ b/src/components/BotMessageWithBodyInput.tsx @@ -54,7 +54,7 @@ const HEIGHTS = { export default function BotMessageWithBodyInput(props: Props) { const { botUser } = useChatContext(); - const { botStudioEditProps, dateLocale } = useConstantState(); + const { botStudioEditProps, dateLocale, stringSet } = useConstantState(); const { createdAt, bodyComponent, chainTop, chainBottom, messageFeedback, wideContainer = false } = props; @@ -86,10 +86,16 @@ export default function BotMessageWithBodyInput(props: Props) { {bodyComponent} {!wideContainer && !!createdAt && ( - {formatCreatedAtToAMPM(createdAt, dateLocale)} + + {formatCreatedAtToAMPM(createdAt, stringSet.DATE_FORMAT__MESSAGE_TIMESTAMP, dateLocale)} + )} - {wideContainer && !!createdAt && {formatCreatedAtToAMPM(createdAt, dateLocale)}} + {wideContainer && !!createdAt && ( + + {formatCreatedAtToAMPM(createdAt, stringSet.DATE_FORMAT__MESSAGE_TIMESTAMP, dateLocale)} + + )} {displayProfileImage && messageFeedback} diff --git a/src/components/MyMessageStatus.tsx b/src/components/MyMessageStatus.tsx index fa539fbed..7a980edda 100644 --- a/src/components/MyMessageStatus.tsx +++ b/src/components/MyMessageStatus.tsx @@ -5,6 +5,7 @@ import { Locale } from 'date-fns'; import { useTheme } from 'styled-components'; import { DefaultSentTime } from './MessageComponent'; +import { useConstantState } from '../context/ConstantContext'; import { Icon } from '../foundation/components/Icon'; import { Loader } from '../foundation/components/Loader'; import { formatCreatedAtToAMPM } from '../utils/messageTimestamp'; @@ -16,6 +17,7 @@ interface MyMessageStatusProps { export default function MyMessageStatus(props: MyMessageStatusProps) { const { message, dateLocale } = props; + const { stringSet } = useConstantState(); const theme = useTheme(); switch (message.sendingStatus) { @@ -32,7 +34,11 @@ export default function MyMessageStatus(props: MyMessageStatusProps) { ); default: - return {formatCreatedAtToAMPM(message.createdAt, dateLocale)}; + return ( + + {formatCreatedAtToAMPM(message.createdAt, stringSet.DATE_FORMAT__MESSAGE_TIMESTAMP, dateLocale)} + + ); } } diff --git a/src/components/UserMessageWithBodyInput.tsx b/src/components/UserMessageWithBodyInput.tsx index b05970085..726bf56a7 100644 --- a/src/components/UserMessageWithBodyInput.tsx +++ b/src/components/UserMessageWithBodyInput.tsx @@ -7,6 +7,7 @@ import styled from 'styled-components'; import Avatar from '@uikit/ui/Avatar'; import { SentTime } from './MessageComponent'; +import { useConstantState } from '../context/ConstantContext'; import { Label } from '../foundation/components/Label'; import { formatCreatedAtToAMPM } from '../utils/messageTimestamp'; @@ -60,6 +61,7 @@ const EmptyImageContainer = styled.div` export default function UserMessageWithBodyInput(props: Props) { const { user, message, bodyComponent, chainTop, chainBottom, locale } = props; + const { stringSet } = useConstantState(); const nonChainedMessage = chainTop == null && chainBottom == null; const displayProfileImage = nonChainedMessage || chainBottom; @@ -82,7 +84,11 @@ export default function UserMessageWithBodyInput(props: Props) { )} {bodyComponent} - {!!message?.createdAt && {formatCreatedAtToAMPM(message.createdAt, locale)}} + {!!message?.createdAt && ( + + {formatCreatedAtToAMPM(message.createdAt, stringSet.DATE_FORMAT__MESSAGE_TIMESTAMP, locale)} + + )} diff --git a/src/components/chat/ui/ChatMessageList.tsx b/src/components/chat/ui/ChatMessageList.tsx index adbbe1893..3db73c2d6 100644 --- a/src/components/chat/ui/ChatMessageList.tsx +++ b/src/components/chat/ui/ChatMessageList.tsx @@ -20,7 +20,7 @@ import { useTypingTargetMessageId } from '../hooks/useTypingTargetMessageId'; export const ChatMessageList = () => { const { channel, dataSource, scrollSource, handlers } = useChatContext(); - const { botStudioEditProps, customUserAgentParam, stringSet, dateLocale } = useConstantState(); + const { botStudioEditProps, customUserAgentParam, stringSet, dateLocale, enableMessageGrouping } = useConstantState(); const typingTargetMessageId = useTypingTargetMessageId(); const { filteredMessages, shouldShowOriginalDate, renderBotStudioWelcomeMessages } = useBotStudioView(); @@ -54,7 +54,12 @@ export const ChatMessageList = () => { const lastMessageInChannel = filteredMessages[filteredMessages.length - 1]; const showRepliesOnLastMessage = message.messageId === lastMessageInChannel?.messageId; - const [top, bottom] = getMessageGrouping(message, filteredMessages[index - 1], filteredMessages[index + 1]); + const [top, bottom] = getMessageGrouping( + message, + filteredMessages[index - 1], + filteredMessages[index + 1], + enableMessageGrouping, + ); return (
diff --git a/src/components/messages/FormMessage.tsx b/src/components/messages/FormMessage.tsx index 433ae4dbc..c0cbbb67f 100644 --- a/src/components/messages/FormMessage.tsx +++ b/src/components/messages/FormMessage.tsx @@ -132,7 +132,7 @@ export default function FormMessage(props: Props) { }; return ( - + {items.map((item, index) => { const { name, placeholder, id, required, style } = item; const { draftValues = [], errorMessage } = formValues[index]; diff --git a/src/const.ts b/src/const.ts index b4574de0e..8aeed23f0 100644 --- a/src/const.ts +++ b/src/const.ts @@ -63,6 +63,7 @@ export const DEFAULT_CONSTANT = { enableMention: true, enableResetHistoryOnConnect: false, enableWidgetExpandButton: false, + enableMessageGrouping: true, dateLocale: enUS, enableHideWidgetForDeactivatedUser: false, messageInputControls: { @@ -316,6 +317,11 @@ interface ConstantFeatureFlags { * @description Enable widget expand button. * */ enableWidgetExpandButton: boolean; + /** + * @public + * @description Enable message grouping by timestamp. + * */ + enableMessageGrouping: boolean; } export interface CreateGroupChannelParams { diff --git a/src/context/ConstantContext.tsx b/src/context/ConstantContext.tsx index 694091b96..757901d7d 100644 --- a/src/context/ConstantContext.tsx +++ b/src/context/ConstantContext.tsx @@ -92,6 +92,7 @@ export const ConstantStateProvider = (props: PropsWithChildren { const urlParams = new URLSearchParams(window.location.search); const appId = urlParams.get('app_id') ?? import.meta.env.VITE_CHAT_WIDGET_APP_ID; const botId = urlParams.get('bot_id') ?? import.meta.env.VITE_CHAT_WIDGET_BOT_ID; + const isSnapshot = urlParams.get('snapshot') === 'true'; + const locale = urlParams.get('locale') ?? undefined; const region = urlParams.get('region') ?? undefined; @@ -25,7 +27,24 @@ const WidgetApp = () => { } const host = getHost(region); - return ; + return ( + + ); }; ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( diff --git a/src/utils/messageTimestamp.ts b/src/utils/messageTimestamp.ts index 30834f623..37d7968c0 100644 --- a/src/utils/messageTimestamp.ts +++ b/src/utils/messageTimestamp.ts @@ -6,7 +6,7 @@ import { enUS } from 'date-fns/locale'; * Note that returned time is computed based on the running app's region but not the injected locale value. * So result varies depending on the location of the running app. */ -export function formatCreatedAtToAMPM(createdAt: number, locale: Locale = enUS) { - const time = format(createdAt || 0, 'p', { locale }); +export function formatCreatedAtToAMPM(createdAt: number, formatString = 'p', locale: Locale = enUS) { + const time = format(createdAt || 0, formatString, { locale }); return time; } diff --git a/src/utils/messages.ts b/src/utils/messages.ts index d51a213ee..163a3a429 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -3,7 +3,15 @@ import { isSameMinute } from 'date-fns'; import { messageExtension } from './messageExtension'; -export const getMessageGrouping = (curr: BaseMessage, prev?: BaseMessage, next?: BaseMessage): [boolean, boolean] => { +export const getMessageGrouping = ( + curr: BaseMessage, + prev?: BaseMessage, + next?: BaseMessage, + enableMessageGrouping = true, +): [boolean, boolean] => { + if (!enableMessageGrouping) { + return [true, true]; + } if (!curr.isUserMessage() && !curr.isFileMessage()) { return [false, false]; } diff --git a/tsconfig.json b/tsconfig.json index afa2f74e5..d02df5a1f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,6 +29,7 @@ "src", "./src/custom.d.ts", "./src/styled-components.d.ts", + "__visual_tests__", ], "exclude": ["node_modules"], "references": [{ "path": "./tsconfig.node.json" }] diff --git a/yarn.lock b/yarn.lock index 78ac00547..d3d0ddd5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2648,6 +2648,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:^1.48.1": + version: 1.48.1 + resolution: "@playwright/test@npm:1.48.1" + dependencies: + playwright: "npm:1.48.1" + bin: + playwright: cli.js + checksum: 10c0/32cedc3b2d375cb8f4a830bc820d7726b0235be7a6202e1d6ee46e739b83666271c47c100c11311cf5a916468c18e6a4dc526accf9ef090786e7614c2633b2b8 + languageName: node + linkType: hard + "@radix-ui/primitive@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/primitive@npm:1.0.1" @@ -3266,7 +3277,9 @@ __metadata: "@linaria/atomic": "npm:^6.2.0" "@linaria/core": "npm:^6.2.0" "@linaria/react": "npm:^6.2.1" + "@playwright/test": "npm:^1.48.1" "@types/dompurify": "npm:^3.0.5" + "@types/node": "npm:^22.7.9" "@types/react": "npm:^18.0.37" "@types/react-dom": "npm:^18.0.11" "@types/styled-components": "npm:^5.1.26" @@ -4842,6 +4855,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^22.7.9": + version: 22.7.9 + resolution: "@types/node@npm:22.7.9" + dependencies: + undici-types: "npm:~6.19.2" + checksum: 10c0/2d1917702b9d9ede8e4d8151cd8b1af8bc147d543486474ffbe0742e38764ea73105939e6a767addf7a4c39e842e16eae762bff90617d7b7f9ee3fbbb2d23bfa + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.4 resolution: "@types/normalize-package-data@npm:2.4.4" @@ -9676,6 +9698,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:2.3.2": + version: 2.3.2 + resolution: "fsevents@npm:2.3.2" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@npm:^2.3.2, fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" @@ -9686,6 +9718,15 @@ __metadata: languageName: node linkType: hard +"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" @@ -14162,6 +14203,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.48.1": + version: 1.48.1 + resolution: "playwright-core@npm:1.48.1" + bin: + playwright-core: cli.js + checksum: 10c0/2f75532b9b7dfa0e586f5660ac1d8ea729bbdbd28dd2c0711e7cfc1adfe5cf7448d7f15a018ec9851a8f50c0743c3990cb9df23064bed603627baeac4dce3915 + languageName: node + linkType: hard + +"playwright@npm:1.48.1": + version: 1.48.1 + resolution: "playwright@npm:1.48.1" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.48.1" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10c0/96280ae656226e52015c0c69c4c19e9f594c19353a79012a19bd7b7175d7b409c1aed289a629df49ef897a57ccd24668ad15b86c283db10f76212a4db90a94ac + languageName: node + linkType: hard + "plop@npm:^2.5.3": version: 2.7.6 resolution: "plop@npm:2.7.6" @@ -17688,6 +17753,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~6.19.2": + version: 6.19.8 + resolution: "undici-types@npm:6.19.8" + checksum: 10c0/078afa5990fba110f6824823ace86073b4638f1d5112ee26e790155f481f2a868cc3e0615505b6f4282bdf74a3d8caad715fd809e870c2bb0704e3ea6082f344 + languageName: node + linkType: hard + "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.0 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.0"