From ee02923ace4fcd376c94aa70ea077ea9c103ea03 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Mon, 7 Oct 2024 15:27:41 +0530 Subject: [PATCH] chore: fix circleci on vite build (#10214) - Switch to pnpm based build - Switch circleci from docker to machine to have more memory - Fix frontend and backend tests Fixes https://linear.app/chatwoot/issue/CW-3610/fix-circle-ci-for-vite-build --------- Co-authored-by: Shivam Mishra Co-authored-by: Pranav Co-authored-by: Pranav --- .circleci/config.yml | 246 ++++++++-------- .circleci/setup_chatwoot.sql | 11 + .codeclimate.yml | 3 +- .eslintrc.js | 25 +- .github/workflows/frontend-fe.yml | 43 +++ .github/workflows/run_foss_spec.yml | 75 ++--- .../dashboard/components/ModalHeader.vue | 4 +- .../components/layout/AvailabilityStatus.vue | 2 +- .../specs/AccountSelector.spec.js | 101 +++---- .../specs/AgentDetails.spec.js | 72 +++-- .../specs/NotificationBell.spec.js | 66 +++-- .../layout/specs/AvailabilityStatus.spec.js | 95 +++--- .../__snapshots__/SidemenuIcon.spec.js.snap | 5 +- .../WootWriter/utils/mp3ConversionUtils.js | 3 + .../widgets/conversation/MessagesView.vue | 1 + .../conversation/specs/MoreActions.spec.js | 108 ++++--- app/javascript/dashboard/composables/index.js | 2 +- .../composables/spec/emitter.spec.js | 34 ++- .../dashboard/composables/spec/index.spec.js | 20 +- .../dashboard/composables/spec/useAI.spec.js | 17 +- .../composables/spec/useAutomation.spec.js | 119 +++----- .../AnalyticsHelper/specs/plugin.spec.js | 36 --- .../helper/specs/ReconnectService.spec.js | 14 +- .../helper/specs/directives/resize.spec.js | 78 ----- .../helper/specs/editorHelper.spec.js | 4 +- .../mixins/specs/fileUploadMixin.spec.js | 71 +++-- .../mixins/specs/portalMixin.spec.js | 42 +-- .../pages/articles/ArticleSettings.vue | 1 + .../reports/components/ReportMetricCard.vue | 10 +- .../components/specs/CSATMetrics.spec.js | 45 +-- .../specs/Filters/FiltersAgents.spec.js | 26 +- .../specs/Filters/FiltersDateGroupBy.spec.js | 8 +- .../specs/Filters/FiltersDateRange.spec.js | 8 +- .../specs/Filters/FiltersInboxes.spec.js | 37 +-- .../specs/Filters/FiltersLabels.spec.js | 37 +-- .../specs/Filters/FiltersRatings.spec.js | 20 +- .../specs/Filters/FiltersTeams.spec.js | 39 +-- .../components/specs/ReportMetricCard.spec.js | 41 +-- .../__snapshots__/CSATMetrics.spec.js.snap | 8 +- app/javascript/dashboard/routes/index.js | 6 +- app/javascript/dashboard/routes/index.spec.js | 156 +++++----- .../helpCenterArticles/specs/mutation.spec.js | 4 +- .../components/specs/DateSeparator.spec.js | 37 ++- .../__snapshots__/DateSeparator.spec.js.snap | 9 + .../specs/__snapshots__/Spinner.spec.js.snap | 2 +- .../whatsappTemplates.spec.js | 36 ++- .../views/dashboard/Index.vue | 1 + .../mixins/specs/availabilityMixin.spec.js | 57 ++-- .../widget/mixins/specs/configMixin.spec.js | 26 +- .../mixins/specs/nextAvailabilityTime.spec.js | 252 +++------------- package.json | 10 +- pnpm-lock.yaml | 272 ++++++++++-------- vite.config.ts | 2 +- vitest.setup.js | 17 ++ 54 files changed, 1130 insertions(+), 1334 deletions(-) create mode 100644 .circleci/setup_chatwoot.sql create mode 100644 .github/workflows/frontend-fe.yml delete mode 100644 app/javascript/dashboard/helper/AnalyticsHelper/specs/plugin.spec.js delete mode 100644 app/javascript/dashboard/helper/specs/directives/resize.spec.js create mode 100644 vitest.setup.js diff --git a/.circleci/config.yml b/.circleci/config.yml index 15c40dc77..459faf1c8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,84 +1,87 @@ -# Ruby CircleCI 2.0 configuration file -# -# Check https://circleci.com/docs/2.0/language-ruby/ for more details -# -version: 2 +version: 2.1 +orbs: + node: circleci/node@6.1.0 + defaults: &defaults working_directory: ~/build - docker: - # specify the version you desire here - - image: cimg/ruby:3.3.3-browsers - - # Specify service dependencies here if necessary - # CircleCI maintains a library of pre-built images - # documented at https://circleci.com/docs/2.0/circleci-images/ - - image: cimg/postgres:15.3 - - image: cimg/redis:6.2.6 - environment: - - RAILS_LOG_TO_STDOUT: false - - COVERAGE: true - - LOG_LEVEL: warn - parallelism: 4 + machine: + image: ubuntu-2204:2024.05.1 resource_class: large + environment: + RAILS_LOG_TO_STDOUT: false + COVERAGE: true + LOG_LEVEL: warn + parallelism: 4 jobs: build: <<: *defaults steps: - checkout + - node/install: + node-version: '20.12' + - node/install-pnpm + - node/install-packages: + pkg-manager: pnpm + override-ci-command: pnpm i + - run: node --version + - run: pnpm --version - run: - name: Configure Bundler + name: Install System Dependencies command: | - echo 'export BUNDLER_VERSION=$(cat Gemfile.lock | tail -1 | tr -d " ")' >> $BASH_ENV - source $BASH_ENV + sudo apt-get update + DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \ + libpq-dev \ + redis-server \ + postgresql \ + build-essential \ + git \ + curl \ + libssl-dev \ + zlib1g-dev \ + libreadline-dev \ + libyaml-dev \ + openjdk-11-jdk \ + jq \ + software-properties-common \ + ca-certificates \ + imagemagick \ + libxml2-dev \ + libxslt1-dev \ + file \ + g++ \ + gcc \ + autoconf \ + gnupg2 \ + patch \ + ruby-dev \ + liblzma-dev \ + libgmp-dev \ + libncurses5-dev \ + libffi-dev \ + libgdbm6 \ + libgdbm-dev \ + libvips + + - run: + name: Install RVM and Ruby 3.3.3 + command: | + sudo apt-get install -y gpg + gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB + \curl -sSL https://get.rvm.io | bash -s stable + echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV + source ~/.rvm/scripts/rvm + rvm install "3.3.3" + rvm use 3.3.3 --default gem install bundler - run: - name: Which bundler? - command: bundle -v - - - run: - name: Swap node versions + name: Install Application Dependencies command: | - set +e - wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash - export NVM_DIR="$HOME/.nvm" - [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" - [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" - nvm install v20 - echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV - echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $BASH_ENV - - # Run bundler - # Load installed gems from cache if possible, bundle install then save cache - # Multiple caches are used to increase the chance of a cache hit - - - restore_cache: - keys: - - chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }} - - - run: bundle install --frozen --path ~/.bundle - - save_cache: - paths: - - ~/.bundle - key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }} - - # Only necessary if app uses webpacker or yarn in some other way - - restore_cache: - keys: - - chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }} - - chatwoot-yarn- - - - run: - name: yarn - command: yarn install --frozen-lockfile --cache-folder ~/.cache/yarn - - # Store yarn / webpacker cache - - save_cache: - key: chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }} - paths: - - ~/.cache/yarn + source ~/.rvm/scripts/rvm + bundle install + # pnpm install - run: name: Download cc-test-reporter @@ -86,12 +89,8 @@ jobs: mkdir -p ~/tmp curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter chmod +x ~/tmp/cc-test-reporter - - persist_to_workspace: - root: ~/tmp - paths: - - cc-test-reporter - # verify swagger specification + # Swagger verification - run: name: Verify swagger API specification command: | @@ -104,45 +103,62 @@ jobs: curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json - # Database setup - - run: bundle exec rake db:create - - run: bundle exec rake db:schema:load + # we remove the FRONTED_URL from the .env before running the tests + - run: + name: Database Setup and Configure Environment Variables + command: | + pg_pass=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 15 ; echo '') + sed -i "s/REPLACE_WITH_PASSWORD/${pg_pass}/g" ${PWD}/.circleci/setup_chatwoot.sql + chmod 644 ${PWD}/.circleci/setup_chatwoot.sql + mv ${PWD}/.circleci/setup_chatwoot.sql /tmp/ + sudo -i -u postgres psql -f /tmp/setup_chatwoot.sql + cp .env.example .env + sed -i '/^FRONTEND_URL/d' .env + sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env + sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env + sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env + sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env + echo -en "\nINSTALLATION_ENV=circleci" >> ".env" + # Database setup + - run: + name: Run DB migrations + command: bundle exec rails db:chatwoot_prepare + + # Bundle audit - run: name: Bundle audit command: bundle exec bundle audit update && bundle exec bundle audit check -v + # Rubocop linting - run: name: Rubocop command: bundle exec rubocop - # - run: - # name: Brakeman - # command: bundle exec brakeman - + # ESLint linting - run: name: eslint - command: yarn run eslint + command: pnpm run eslint - # Run frontend tests - run: name: Run frontend tests command: | - mkdir -p ~/tmp/test-results/frontend_specs + mkdir -p ~/build/coverage/frontend ~/tmp/cc-test-reporter before-build - yarn test:coverage - - run: - name: Code Climate Test Coverage - command: | - ~/tmp/cc-test-reporter format-coverage -t lcov -o "coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json" + pnpm run test:coverage - # Run rails tests + - run: + name: Code Climate Test Coverage (Frontend) + command: | + ~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json" + + # Run backend tests - run: name: Run backend tests command: | mkdir -p ~/tmp/test-results/rspec mkdir -p ~/tmp/test-artifacts - mkdir -p coverage + mkdir -p ~/build/coverage/backend ~/tmp/cc-test-reporter before-build TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) bundle exec rspec --format progress \ @@ -150,54 +166,18 @@ jobs: --out ~/tmp/test-results/rspec.xml \ -- ${TESTFILES} no_output_timeout: 30m + - run: - name: Code Climate Test Coverage + name: Code Climate Test Coverage (Backend) command: | - ~/tmp/cc-test-reporter format-coverage -t simplecov -o "coverage/codeclimate.$CIRCLE_NODE_INDEX.json" + ~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json" + + - run: + name: List coverage directory contents + command: | + ls -R ~/build/coverage - persist_to_workspace: - root: coverage + root: ~/build paths: - - codeclimate.*.json - # collect reports - - store_test_results: - path: ~/tmp/test-results - - store_artifacts: - path: ~/tmp/test-artifacts - - store_artifacts: - path: log - - upload-coverage: - working_directory: ~/build - docker: - # specify the version you desire here - - image: circleci/ruby:3.0.2-node-browsers - environment: - - CC_TEST_REPORTER_ID: caf26a895e937974a90860cfadfded20891cfd1373a5aaafb3f67406ab9d433f - steps: - - attach_workspace: - at: ~/build - - run: - name: Download cc-test-reporter - command: | - mkdir -p ~/tmp - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter - chmod +x ~/tmp/cc-test-reporter - - persist_to_workspace: - root: ~/tmp - paths: - - cc-test-reporter - - run: - name: Upload coverage results to Code Climate - command: | - ~/tmp/cc-test-reporter sum-coverage --output - codeclimate.*.json | ~/tmp/cc-test-reporter upload-coverage --debug --input - - -workflows: - version: 2 - - commit: - jobs: - - build - - upload-coverage: - requires: - - build + - coverage diff --git a/.circleci/setup_chatwoot.sql b/.circleci/setup_chatwoot.sql new file mode 100644 index 000000000..4e5430f1d --- /dev/null +++ b/.circleci/setup_chatwoot.sql @@ -0,0 +1,11 @@ +CREATE USER chatwoot CREATEDB; +ALTER USER chatwoot PASSWORD 'REPLACE_WITH_PASSWORD'; +ALTER ROLE chatwoot SUPERUSER; + +UPDATE pg_database SET datistemplate = FALSE WHERE datname = 'template1'; +DROP DATABASE template1; +CREATE DATABASE template1 WITH TEMPLATE = template0 ENCODING = 'UNICODE'; +UPDATE pg_database SET datistemplate = TRUE WHERE datname = 'template1'; + +\c template1; +VACUUM FREEZE; diff --git a/.codeclimate.yml b/.codeclimate.yml index d8b8d985b..5e16262da 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -27,7 +27,8 @@ checks: threshold: 50 exclude_patterns: - 'spec/' - - '**/specs/' + - '**/specs/**/**' + - '**/spec/**/**' - 'db/*' - 'bin/**/*' - 'db/**/*' diff --git a/.eslintrc.js b/.eslintrc.js index cf4d1e7e1..a229133a2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,6 +1,23 @@ module.exports = { - extends: ['airbnb-base/legacy', 'prettier', 'plugin:vue/vue3-recommended'], + extends: [ + 'airbnb-base/legacy', + 'prettier', + 'plugin:vue/vue3-recommended', + 'plugin:vitest-globals/recommended', + ], + overrides: [ + { + files: ['**/*.spec.{j,t}s?(x)'], + env: { + 'vitest-globals/env': true, + }, + }, + ], plugins: ['html', 'prettier'], + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, rules: { 'prettier/prettier': ['error'], camelcase: 'off', @@ -206,5 +223,11 @@ module.exports = { globals: { bus: true, vi: true, + // beforeEach: true, + // afterEach: true, + // test: true, + // describe: true, + // it: true, + // expect: true, }, }; diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml new file mode 100644 index 000000000..15bb6f5e9 --- /dev/null +++ b/.github/workflows/frontend-fe.yml @@ -0,0 +1,43 @@ +name: Frontend Lint & Test + +on: + push: + branches: + - develop + pull_request: + branches: + - develop + +jobs: + test: + runs-on: ubuntu-20.04 + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - uses: pnpm/action-setup@v4 + with: + version: 9.3.0 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + + - name: Install pnpm dependencies + run: pnpm install --frozen-lockfile + + - name: Run eslint + run: pnpm run eslint + + - name: Run frontend tests with coverage + run: | + mkdir -p coverage + pnpm run test:coverage diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index 9382ccbf6..6a018b314 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -21,7 +21,7 @@ jobs: image: postgres:15.3 env: POSTGRES_USER: postgres - POSTGRES_PASSWORD: "" + POSTGRES_PASSWORD: '' POSTGRES_DB: postgres POSTGRES_HOST_AUTH_METHOD: trust ports: @@ -41,46 +41,49 @@ jobs: options: --entrypoint redis-server steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - version: 9 - ref: ${{ github.event.pull_request.head.ref }} - repository: ${{ github.event.pull_request.head.repo.full_name }} + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9 + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} - - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true # runs 'bundle install' and caches installed gems automatically - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: 'pnpm' + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' - - name: Install pnpm dependencies - run: pnpm i + - name: Install pnpm dependencies + run: pnpm i - - name: Strip enterprise code - run: | - rm -rf enterprise - rm -rf spec/enterprise + - name: Strip enterprise code + run: | + rm -rf enterprise + rm -rf spec/enterprise - - name: Create database - run: bundle exec rake db:create + - name: Create database + run: bundle exec rake db:create - - name: Seed database - run: bundle exec rake db:schema:load + - name: Seed database + run: bundle exec rake db:schema:load - # Run rails tests - - name: Run backend tests - run: | - bundle exec rspec --profile=10 --format documentation - env: - NODE_OPTIONS: --openssl-legacy-provider + - name: Run frontend tests + run: pnpm run test:coverage - - name: Upload rails log folder - uses: actions/upload-artifact@v4 - if: always() - with: - name: rails-log-folder - path: log + # Run rails tests + - name: Run backend tests + run: | + bundle exec rspec --profile=10 --format documentation + env: + NODE_OPTIONS: --openssl-legacy-provider + + - name: Upload rails log folder + uses: actions/upload-artifact@v4 + if: always() + with: + name: rails-log-folder + path: log diff --git a/app/javascript/dashboard/components/ModalHeader.vue b/app/javascript/dashboard/components/ModalHeader.vue index 63d361278..9e2330aa4 100644 --- a/app/javascript/dashboard/components/ModalHeader.vue +++ b/app/javascript/dashboard/components/ModalHeader.vue @@ -27,14 +27,14 @@ export default {
No image

{{ headerTitle }}

{{ headerContent }} diff --git a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue index 4fce895b2..8bce09250 100644 --- a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue +++ b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue @@ -105,7 +105,7 @@ export default { size="small" :color-scheme="status.disabled ? '' : 'secondary'" :variant="status.disabled ? 'smooth' : 'clear'" - class-names="status-change--dropdown-button" + class="status-change--dropdown-button" @click="changeAvailabilityStatus(status.value)" > diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/specs/AccountSelector.spec.js b/app/javascript/dashboard/components/layout/sidebarComponents/specs/AccountSelector.spec.js index fb76e0ff4..1af49195f 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/specs/AccountSelector.spec.js +++ b/app/javascript/dashboard/components/layout/sidebarComponents/specs/AccountSelector.spec.js @@ -1,79 +1,62 @@ +import { mount } from '@vue/test-utils'; +import { createStore } from 'vuex'; import AccountSelector from '../AccountSelector.vue'; -import { createLocalVue, mount } from '@vue/test-utils'; -import Vuex from 'vuex'; -import VueI18n from 'vue-i18n'; - -import i18n from 'dashboard/i18n'; import WootModal from 'dashboard/components/Modal.vue'; import WootModalHeader from 'dashboard/components/ModalHeader.vue'; import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; -const localVue = createLocalVue(); -localVue.component('woot-modal', WootModal); -localVue.component('woot-modal-header', WootModalHeader); -localVue.component('fluent-icon', FluentIcon); - -localVue.use(Vuex); -localVue.use(VueI18n); - -const i18nConfig = new VueI18n({ - locale: 'en', - messages: i18n, +const store = createStore({ + modules: { + auth: { + namespaced: false, + getters: { + getCurrentAccountId: () => 1, + getCurrentUser: () => ({ + accounts: [ + { id: 1, name: 'Chatwoot', role: 'administrator' }, + { id: 2, name: 'GitX', role: 'agent' }, + ], + }), + }, + }, + globalConfig: { + namespaced: true, + getters: { + get: () => ({ createNewAccountFromDashboard: false }), + }, + }, + }, }); -describe('accountSelctor', () => { +describe('AccountSelector', () => { let accountSelector = null; - const currentUser = { - accounts: [ - { - id: 1, - name: 'Chatwoot', - role: 'administrator', - }, - { - id: 2, - name: 'GitX', - role: 'agent', - }, - ], - }; - - let actions = null; - let modules = null; beforeEach(() => { - actions = {}; - modules = { - auth: { - getters: { - getCurrentAccountId: () => 1, - getCurrentUser: () => currentUser, - }, - }, - globalConfig: { - getters: { - 'globalConfig/get': () => ({ createNewAccountFromDashboard: false }), - }, - }, - }; - - let store = new Vuex.Store({ actions, modules }); accountSelector = mount(AccountSelector, { - store, - localVue, - i18n: i18nConfig, - propsData: { showAccountModal: true }, - stubs: { WootButton: { template: '' } }, + }, }); }); - it(' the agent status', () => { - expect(agentDetails.find('thumbnail-stub').vm.status).toBe('online'); + it('shows the correct agent status', () => { + expect(agentDetails.findComponent(Thumbnail).vm.status).toBe('online'); }); it('agent thumbnail exists', () => { diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/specs/NotificationBell.spec.js b/app/javascript/dashboard/components/layout/sidebarComponents/specs/NotificationBell.spec.js index 85596bb3e..607604d48 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/specs/NotificationBell.spec.js +++ b/app/javascript/dashboard/components/layout/sidebarComponents/specs/NotificationBell.spec.js @@ -1,20 +1,7 @@ -import NotificationBell from '../NotificationBell.vue'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import Vuex from 'vuex'; -import VueI18n from 'vue-i18n'; +import { shallowMount } from '@vue/test-utils'; +import { createStore } from 'vuex'; import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; - -import i18n from 'dashboard/i18n'; - -const localVue = createLocalVue(); -localVue.use(Vuex); -localVue.use(VueI18n); -localVue.component('fluent-icon', FluentIcon); - -const i18nConfig = new VueI18n({ - locale: 'en', - messages: i18n, -}); +import NotificationBell from '../NotificationBell.vue'; const $route = { name: 'notifications_index', @@ -33,43 +20,51 @@ describe('notificationBell', () => { }; modules = { auth: { + namespaced: false, getters: { getCurrentAccountId: () => accountId, }, }, notifications: { + namespaced: false, getters: { 'notifications/getMeta': () => notificationMetadata, }, }, }; - store = new Vuex.Store({ + store = createStore({ actions, modules, }); }); - it('it should return unread count 19 ', () => { + it('it should return unread count 19', () => { const wrapper = shallowMount(NotificationBell, { - localVue, - i18n: i18nConfig, - store, - mocks: { - $route, + global: { + plugins: [store], + mocks: { + $route, + }, + components: { + 'fluent-icon': FluentIcon, + }, }, }); expect(wrapper.vm.unreadCount).toBe('19'); }); - it('it should return unread count 99+ ', async () => { + it('it should return unread count 99+', async () => { notificationMetadata.unreadCount = 100; const wrapper = shallowMount(NotificationBell, { - localVue, - i18n: i18nConfig, - store, - mocks: { - $route, + global: { + plugins: [store], + mocks: { + $route, + }, + components: { + 'fluent-icon': FluentIcon, + }, }, }); expect(wrapper.vm.unreadCount).toBe('99+'); @@ -77,11 +72,14 @@ describe('notificationBell', () => { it('isNotificationPanelActive', async () => { const notificationBell = shallowMount(NotificationBell, { - store, - localVue, - i18n: i18nConfig, - mocks: { - $route, + global: { + plugins: [store], + mocks: { + $route, + }, + components: { + 'fluent-icon': FluentIcon, + }, }, }); diff --git a/app/javascript/dashboard/components/layout/specs/AvailabilityStatus.spec.js b/app/javascript/dashboard/components/layout/specs/AvailabilityStatus.spec.js index c70cffa13..2a9dbaba0 100644 --- a/app/javascript/dashboard/components/layout/specs/AvailabilityStatus.spec.js +++ b/app/javascript/dashboard/components/layout/specs/AvailabilityStatus.spec.js @@ -1,9 +1,6 @@ +import { mount } from '@vue/test-utils'; +import { createStore } from 'vuex'; import AvailabilityStatus from '../AvailabilityStatus.vue'; -import { createLocalVue, mount } from '@vue/test-utils'; -import Vuex from 'vuex'; -import VueI18n from 'vue-i18n'; -import FloatingVue from 'floating-vue'; - import WootButton from 'dashboard/components/ui/WootButton.vue'; import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; @@ -11,70 +8,64 @@ import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider.vue'; import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; -import i18n from 'dashboard/i18n'; - -const localVue = createLocalVue(); -localVue.use(FloatingVue, { - html: false, -}); -localVue.use(Vuex); -localVue.use(VueI18n); -localVue.component('woot-button', WootButton); -localVue.component('woot-dropdown-header', WootDropdownHeader); -localVue.component('woot-dropdown-menu', WootDropdownMenu); -localVue.component('woot-dropdown-divider', WootDropdownDivider); -localVue.component('woot-dropdown-item', WootDropdownItem); -localVue.component('fluent-icon', FluentIcon); - -const i18nConfig = new VueI18n({ locale: 'en', messages: i18n }); - describe('AvailabilityStatus', () => { const currentAvailability = 'online'; const currentAccountId = '1'; const currentUserAutoOffline = false; let store = null; let actions = null; - let modules = null; - let availabilityStatus = null; beforeEach(() => { actions = { - updateAvailability: vi.fn(() => { - return Promise.resolve(); - }), + updateAvailability: vi.fn(() => Promise.resolve()), }; - modules = { - auth: { - getters: { - getCurrentUserAvailability: () => currentAvailability, - getCurrentAccountId: () => currentAccountId, - getCurrentUserAutoOffline: () => currentUserAutoOffline, + store = createStore({ + modules: { + auth: { + namespaced: false, + getters: { + getCurrentUserAvailability: () => currentAvailability, + getCurrentAccountId: () => currentAccountId, + getCurrentUserAutoOffline: () => currentUserAutoOffline, + }, }, }, - }; - - store = new Vuex.Store({ actions, modules }); - - availabilityStatus = mount(AvailabilityStatus, { - store, - localVue, - i18n: i18nConfig, - stubs: { WootSwitch: { template: ' `; diff --git a/app/javascript/dashboard/components/widgets/WootWriter/utils/mp3ConversionUtils.js b/app/javascript/dashboard/components/widgets/WootWriter/utils/mp3ConversionUtils.js index 373d9dfd2..3ae37911d 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/utils/mp3ConversionUtils.js +++ b/app/javascript/dashboard/components/widgets/WootWriter/utils/mp3ConversionUtils.js @@ -1,6 +1,7 @@ import lamejs from '@breezystack/lamejs'; const writeString = (view, offset, string) => { + // eslint-disable-next-line no-plusplus for (let i = 0; i < string.length; i++) { view.setUint8(offset + i, string.charCodeAt(i)); } @@ -28,7 +29,9 @@ const bufferToWav = async (buffer, numChannels, sampleRate) => { // WAV Data const offset = 44; + // eslint-disable-next-line no-plusplus for (let i = 0; i < buffer.length; i++) { + // eslint-disable-next-line no-plusplus for (let channel = 0; channel < numChannels; channel++) { const sample = Math.max( -1, diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue index f4eeedc32..0b19ea2bf 100644 --- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue +++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue @@ -478,6 +478,7 @@ export default {

    +
  • diff --git a/app/javascript/dashboard/components/widgets/conversation/specs/MoreActions.spec.js b/app/javascript/dashboard/components/widgets/conversation/specs/MoreActions.spec.js index c1e931dcb..ba37c3b22 100644 --- a/app/javascript/dashboard/components/widgets/conversation/specs/MoreActions.spec.js +++ b/app/javascript/dashboard/components/widgets/conversation/specs/MoreActions.spec.js @@ -1,11 +1,7 @@ -import { createLocalVue, mount } from '@vue/test-utils'; -import Vuex from 'vuex'; -import VueI18n from 'vue-i18n'; -import FloatingVue from 'floating-vue'; -import Button from 'dashboard/components/buttons/Button.vue'; -import i18n from 'dashboard/i18n'; -import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; +import { mount } from '@vue/test-utils'; +import { createStore } from 'vuex'; import MoreActions from '../MoreActions.vue'; +import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue'; vi.mock('shared/helpers/mitt', () => ({ emitter: { @@ -15,75 +11,67 @@ vi.mock('shared/helpers/mitt', () => ({ }, })); -import { emitter } from 'shared/helpers/mitt'; - -const localVue = createLocalVue(); -localVue.use(Vuex); -localVue.use(VueI18n); -localVue.use(FloatingVue); - -localVue.component('fluent-icon', FluentIcon); -localVue.component('woot-button', Button); - -localVue.prototype.$emitter = { - emit: vi.fn(), - on: vi.fn(), - off: vi.fn(), +const mockDirective = { + mounted: () => {}, }; -const i18nConfig = new VueI18n({ locale: 'en', messages: i18n }); +import { emitter } from 'shared/helpers/mitt'; describe('MoveActions', () => { let currentChat = { id: 8, muted: false }; - let state = null; + let store = null; let muteConversation = null; let unmuteConversation = null; - let modules = null; - let getters = null; - let store = null; - let moreActions = null; beforeEach(() => { - state = { - authenticated: true, - currentChat, - }; - muteConversation = vi.fn(() => Promise.resolve()); unmuteConversation = vi.fn(() => Promise.resolve()); - modules = { - conversations: { actions: { muteConversation, unmuteConversation } }, - }; - - getters = { getSelectedChat: () => currentChat }; - - store = new Vuex.Store({ state, modules, getters }); - - moreActions = mount(MoreActions, { - store, - localVue, - i18n: i18nConfig, - stubs: { - WootModal: { template: '
    ' }, - WootModalHeader: { template: '
    ' }, + store = createStore({ + state: { + authenticated: true, + currentChat, + }, + getters: { + getSelectedChat: () => currentChat, + }, + modules: { + conversations: { + namespaced: false, + actions: { muteConversation, unmuteConversation }, + }, }, }); }); + const createWrapper = () => + mount(MoreActions, { + global: { + plugins: [store], + components: { + 'fluent-icon': FluentIcon, + }, + directives: { + 'on-clickaway': mockDirective, + }, + }, + }); + describe('muting discussion', () => { it('triggers "muteConversation"', async () => { - await moreActions.find('button:first-child').trigger('click'); + const wrapper = createWrapper(); + await wrapper.find('button:first-child').trigger('click'); - expect(muteConversation).toBeCalledWith( - expect.any(Object), - currentChat.id, - undefined + expect(muteConversation).toHaveBeenCalledTimes(1); + expect(muteConversation).toHaveBeenCalledWith( + expect.any(Object), // First argument is the Vuex context object + currentChat.id // Second argument is the ID of the conversation ); }); it('shows alert', async () => { - await moreActions.find('button:first-child').trigger('click'); + const wrapper = createWrapper(); + await wrapper.find('button:first-child').trigger('click'); expect(emitter.emit).toBeCalledWith('newToastMessage', { message: @@ -99,17 +87,19 @@ describe('MoveActions', () => { }); it('triggers "unmuteConversation"', async () => { - await moreActions.find('button:first-child').trigger('click'); + const wrapper = createWrapper(); + await wrapper.find('button:first-child').trigger('click'); - expect(unmuteConversation).toBeCalledWith( - expect.any(Object), - currentChat.id, - undefined + expect(unmuteConversation).toHaveBeenCalledTimes(1); + expect(unmuteConversation).toHaveBeenCalledWith( + expect.any(Object), // First argument is the Vuex context object + currentChat.id // Second argument is the ID of the conversation ); }); it('shows alert', async () => { - await moreActions.find('button:first-child').trigger('click'); + const wrapper = createWrapper(); + await wrapper.find('button:first-child').trigger('click'); expect(emitter.emit).toBeCalledWith('newToastMessage', { message: 'This contact is unblocked successfully.', diff --git a/app/javascript/dashboard/composables/index.js b/app/javascript/dashboard/composables/index.js index 7dbfbff95..cf4cdf834 100644 --- a/app/javascript/dashboard/composables/index.js +++ b/app/javascript/dashboard/composables/index.js @@ -1,5 +1,5 @@ import { emitter } from 'shared/helpers/mitt'; -import analyticsHelper from '/dashboard/helper/AnalyticsHelper/index'; +import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index'; /** * Custom hook to track events diff --git a/app/javascript/dashboard/composables/spec/emitter.spec.js b/app/javascript/dashboard/composables/spec/emitter.spec.js index 22f2b34b8..0a932d8b8 100644 --- a/app/javascript/dashboard/composables/spec/emitter.spec.js +++ b/app/javascript/dashboard/composables/spec/emitter.spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import { emitter } from 'shared/helpers/mitt'; import { useEmitter } from '../emitter'; +import { defineComponent } from 'vue'; vi.mock('shared/helpers/mitt', () => ({ emitter: { @@ -10,31 +11,34 @@ vi.mock('shared/helpers/mitt', () => ({ })); describe('useEmitter', () => { - let wrapper; const eventName = 'my-event'; const callback = vi.fn(); + let wrapper; + + const TestComponent = defineComponent({ + setup() { + return { + cleanup: useEmitter(eventName, callback), + }; + }, + template: '
    Hello world
    ', + }); + beforeEach(() => { - wrapper = shallowMount({ - template: ` -
    - Hello world -
    - `, - setup() { - return { - cleanup: useEmitter(eventName, callback), - }; - }, - }); + wrapper = shallowMount(TestComponent); + }); + + afterEach(() => { + vi.clearAllMocks(); }); it('should add an event listener on mount', () => { expect(emitter.on).toHaveBeenCalledWith(eventName, callback); }); - it('should remove the event listener when the component is unmounted', () => { - wrapper.destroy(); + it('should remove the event listener when the component is unmounted', async () => { + await wrapper.unmount(); expect(emitter.off).toHaveBeenCalledWith(eventName, callback); }); diff --git a/app/javascript/dashboard/composables/spec/index.spec.js b/app/javascript/dashboard/composables/spec/index.spec.js index 80ea2b638..dc1016af8 100644 --- a/app/javascript/dashboard/composables/spec/index.spec.js +++ b/app/javascript/dashboard/composables/spec/index.spec.js @@ -1,20 +1,26 @@ -import { getCurrentInstance } from 'vue'; import { emitter } from 'shared/helpers/mitt'; +import analyticsHelper from 'dashboard/helper/AnalyticsHelper'; import { useTrack, useAlert } from '../index'; -vi.mock('vue', () => ({ - getCurrentInstance: vi.fn(), -})); vi.mock('shared/helpers/mitt', () => ({ emitter: { emit: vi.fn(), }, })); +vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => { + const actual = await importOriginal(); + actual.default = { + track: vi.fn(), + }; + return actual; +}); + describe('useTrack', () => { - it('should return a function', () => { - const track = useTrack(); - expect(typeof track).toBe('function'); + it('should call analyticsHelper.track and return a function', () => { + const eventArgs = ['event-name', { some: 'data' }]; + useTrack(...eventArgs); + expect(analyticsHelper.track).toHaveBeenCalledWith(...eventArgs); }); }); diff --git a/app/javascript/dashboard/composables/spec/useAI.spec.js b/app/javascript/dashboard/composables/spec/useAI.spec.js index 7550f801a..2431fe196 100644 --- a/app/javascript/dashboard/composables/spec/useAI.spec.js +++ b/app/javascript/dashboard/composables/spec/useAI.spec.js @@ -4,14 +4,20 @@ import { useStoreGetters, useMapGetter, } from 'dashboard/composables/store'; -import { useAlert, useTrack } from 'dashboard/composables'; import { useI18n } from 'vue-i18n'; import OpenAPI from 'dashboard/api/integrations/openapi'; +import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index'; vi.mock('dashboard/composables/store'); -vi.mock('dashboard/composables'); vi.mock('vue-i18n'); vi.mock('dashboard/api/integrations/openapi'); +vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => { + const actual = await importOriginal(); + actual.default = { + track: vi.fn(), + }; + return actual; +}); vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({ OPEN_AI_EVENTS: { TEST_EVENT: 'open_ai_test_event', @@ -40,9 +46,7 @@ describe('useAI', () => { }; return { value: mockValues[getter] }; }); - useTrack.mockReturnValue(vi.fn()); useI18n.mockReturnValue({ t: vi.fn() }); - useAlert.mockReturnValue(vi.fn()); }); it('initializes computed properties correctly', async () => { @@ -78,13 +82,12 @@ describe('useAI', () => { }); it('records analytics correctly', async () => { - const mockTrack = vi.fn(); - useTrack.mockReturnValue(mockTrack); + // const mockTrack = analyticsHelper.track; const { recordAnalytics } = useAI(); await recordAnalytics('TEST_EVENT', { data: 'test' }); - expect(mockTrack).toHaveBeenCalledWith('open_ai_test_event', { + expect(analyticsHelper.track).toHaveBeenCalledWith('open_ai_test_event', { type: 'TEST_EVENT', data: 'test', }); diff --git a/app/javascript/dashboard/composables/spec/useAutomation.spec.js b/app/javascript/dashboard/composables/spec/useAutomation.spec.js index c0881730b..c8acae2c6 100644 --- a/app/javascript/dashboard/composables/spec/useAutomation.spec.js +++ b/app/javascript/dashboard/composables/spec/useAutomation.spec.js @@ -1,7 +1,7 @@ import { useAutomation } from '../useAutomation'; import { useStoreGetters, useMapGetter } from 'dashboard/composables/store'; import { useAlert } from 'dashboard/composables'; -import { useI18n } from '../useI18n'; +import { useI18n } from 'vue-i18n'; import * as automationHelper from 'dashboard/helper/automationHelper'; import { customAttributes, @@ -20,7 +20,7 @@ import { MESSAGE_CONDITION_VALUES } from 'dashboard/constants/automation'; vi.mock('dashboard/composables/store'); vi.mock('dashboard/composables'); -vi.mock('../useI18n'); +vi.mock('vue-i18n'); vi.mock('dashboard/helper/automationHelper'); describe('useAutomation', () => { @@ -120,8 +120,8 @@ describe('useAutomation', () => { }); it('appends new condition and action correctly', () => { - const { appendNewCondition, appendNewAction } = useAutomation(); - const mockAutomation = { + const { appendNewCondition, appendNewAction, automation } = useAutomation(); + automation.value = { event_name: 'message_created', conditions: [], actions: [], @@ -130,36 +130,37 @@ describe('useAutomation', () => { automationHelper.getDefaultConditions.mockReturnValue([{}]); automationHelper.getDefaultActions.mockReturnValue([{}]); - appendNewCondition(mockAutomation); - appendNewAction(mockAutomation); + appendNewCondition(); + appendNewAction(); expect(automationHelper.getDefaultConditions).toHaveBeenCalledWith( 'message_created' ); expect(automationHelper.getDefaultActions).toHaveBeenCalled(); - expect(mockAutomation.conditions).toHaveLength(1); - expect(mockAutomation.actions).toHaveLength(1); + expect(automation.value.conditions).toHaveLength(1); + expect(automation.value.actions).toHaveLength(1); }); it('removes filter and action correctly', () => { - const { removeFilter, removeAction } = useAutomation(); - const mockAutomation = { + const { removeFilter, removeAction, automation } = useAutomation(); + automation.value = { conditions: [{ id: 1 }, { id: 2 }], actions: [{ id: 1 }, { id: 2 }], }; - removeFilter(mockAutomation, 0); - removeAction(mockAutomation, 0); + removeFilter(0); + removeAction(0); - expect(mockAutomation.conditions).toHaveLength(1); - expect(mockAutomation.actions).toHaveLength(1); - expect(mockAutomation.conditions[0].id).toBe(2); - expect(mockAutomation.actions[0].id).toBe(2); + expect(automation.value.conditions).toHaveLength(1); + expect(automation.value.actions).toHaveLength(1); + expect(automation.value.conditions[0].id).toBe(2); + expect(automation.value.actions[0].id).toBe(2); }); it('resets filter and action correctly', () => { - const { resetFilter, resetAction } = useAutomation(); - const mockAutomation = { + const { resetFilter, resetAction, automation, automationTypes } = + useAutomation(); + automation.value = { event_name: 'message_created', conditions: [ { @@ -170,77 +171,37 @@ describe('useAutomation', () => { ], actions: [{ action_name: 'assign_agent', action_params: [1] }], }; - const mockAutomationTypes = { - message_created: { - conditions: [ - { key: 'status', filterOperators: [{ value: 'not_equal_to' }] }, - ], - }, + automationTypes.message_created = { + conditions: [ + { key: 'status', filterOperators: [{ value: 'not_equal_to' }] }, + ], }; - resetFilter( - mockAutomation, - mockAutomationTypes, - 0, - mockAutomation.conditions[0] - ); - resetAction(mockAutomation, 0); + resetFilter(0, automation.value.conditions[0]); + resetAction(0); - expect(mockAutomation.conditions[0].filter_operator).toBe('not_equal_to'); - expect(mockAutomation.conditions[0].values).toBe(''); - expect(mockAutomation.actions[0].action_params).toEqual([]); - }); - - it('formats automation correctly', () => { - const { formatAutomation } = useAutomation(); - const mockAutomation = { - conditions: [{ attribute_key: 'status', values: ['open'] }], - actions: [{ action_name: 'assign_agent', action_params: [1] }], - }; - const mockAutomationTypes = {}; - const mockAutomationActionTypes = [ - { key: 'assign_agent', inputType: 'search_select' }, - ]; - - automationHelper.getConditionOptions.mockReturnValue([ - { id: 'open', name: 'open' }, - ]); - automationHelper.getActionOptions.mockReturnValue([ - { id: 1, name: 'Agent 1' }, - ]); - - const result = formatAutomation( - mockAutomation, - customAttributes, - mockAutomationTypes, - mockAutomationActionTypes - ); - - expect(result.conditions[0].values).toEqual([{ id: 'open', name: 'open' }]); - expect(result.actions[0].action_params).toEqual([ - { id: 1, name: 'Agent 1' }, - ]); + expect(automation.value.conditions[0].filter_operator).toBe('not_equal_to'); + expect(automation.value.conditions[0].values).toBe(''); + expect(automation.value.actions[0].action_params).toEqual([]); }); it('manifests custom attributes correctly', () => { - const { manifestCustomAttributes } = useAutomation(); - const mockAutomationTypes = { - message_created: { conditions: [] }, - conversation_created: { conditions: [] }, - conversation_updated: { conditions: [] }, - conversation_opened: { conditions: [] }, - }; + const { manifestCustomAttributes, automationTypes } = useAutomation(); + automationTypes.message_created = { conditions: [] }; + automationTypes.conversation_created = { conditions: [] }; + automationTypes.conversation_updated = { conditions: [] }; + automationTypes.conversation_opened = { conditions: [] }; automationHelper.generateCustomAttributeTypes.mockReturnValue([]); automationHelper.generateCustomAttributes.mockReturnValue([]); - manifestCustomAttributes(mockAutomationTypes); + manifestCustomAttributes(); expect(automationHelper.generateCustomAttributeTypes).toHaveBeenCalledTimes( 2 ); expect(automationHelper.generateCustomAttributes).toHaveBeenCalledTimes(1); - Object.values(mockAutomationTypes).forEach(type => { + Object.values(automationTypes).forEach(type => { expect(type.conditions).toHaveLength(0); }); }); @@ -273,8 +234,8 @@ describe('useAutomation', () => { }); it('handles event change correctly', () => { - const { onEventChange } = useAutomation(); - const mockAutomation = { + const { onEventChange, automation } = useAutomation(); + automation.value = { event_name: 'message_created', conditions: [], actions: [], @@ -283,13 +244,13 @@ describe('useAutomation', () => { automationHelper.getDefaultConditions.mockReturnValue([{}]); automationHelper.getDefaultActions.mockReturnValue([{}]); - onEventChange(mockAutomation); + onEventChange(); expect(automationHelper.getDefaultConditions).toHaveBeenCalledWith( 'message_created' ); expect(automationHelper.getDefaultActions).toHaveBeenCalled(); - expect(mockAutomation.conditions).toHaveLength(1); - expect(mockAutomation.actions).toHaveLength(1); + expect(automation.value.conditions).toHaveLength(1); + expect(automation.value.actions).toHaveLength(1); }); }); diff --git a/app/javascript/dashboard/helper/AnalyticsHelper/specs/plugin.spec.js b/app/javascript/dashboard/helper/AnalyticsHelper/specs/plugin.spec.js deleted file mode 100644 index c1304b154..000000000 --- a/app/javascript/dashboard/helper/AnalyticsHelper/specs/plugin.spec.js +++ /dev/null @@ -1,36 +0,0 @@ -import Vue from 'vue'; -import plugin from '../plugin'; -import analyticsHelper from '../index'; - -vi.spyOn(analyticsHelper, 'init'); -vi.spyOn(analyticsHelper, 'track'); - -describe('Vue Analytics Plugin', () => { - beforeEach(() => { - Vue.use(plugin); - }); - - it('should call the init method on analyticsHelper once during plugin installation', () => { - expect(analyticsHelper.init).toHaveBeenCalledTimes(1); - }); - - it('should add the analyticsHelper to the Vue prototype as $analytics', () => { - expect(Vue.prototype.$analytics).toBe(analyticsHelper); - }); - - it('should add a track method to the Vue prototype as $track', () => { - expect(typeof Vue.prototype.$track).toBe('function'); - Vue.prototype.$track('eventName'); - expect(analyticsHelper.track) - .toHaveBeenCalledTimes(1) - .toHaveBeenCalledWith('eventName'); - }); - - it('should call the track method on analyticsHelper with the correct event name when $track is called', () => { - const eventName = 'testEvent'; - Vue.prototype.$track(eventName); - expect(analyticsHelper.track) - .toHaveBeenCalledTimes(1) - .toHaveBeenCalledWith(eventName); - }); -}); diff --git a/app/javascript/dashboard/helper/specs/ReconnectService.spec.js b/app/javascript/dashboard/helper/specs/ReconnectService.spec.js index 3ef2c8286..60bd825ee 100644 --- a/app/javascript/dashboard/helper/specs/ReconnectService.spec.js +++ b/app/javascript/dashboard/helper/specs/ReconnectService.spec.js @@ -37,8 +37,10 @@ const storeMock = { const routerMock = { currentRoute: { - name: '', - params: { conversation_id: null }, + value: { + name: '', + params: { conversation_id: null }, + }, }, }; @@ -222,7 +224,7 @@ describe('ReconnectService', () => { describe('fetchConversationMessagesOnReconnect', () => { it('should dispatch syncActiveConversationMessages if conversationId exists', async () => { - routerMock.currentRoute.params.conversation_id = 1; + routerMock.currentRoute.value.params.conversation_id = 1; await reconnectService.fetchConversationMessagesOnReconnect(); expect(storeMock.dispatch).toHaveBeenCalledWith( 'syncActiveConversationMessages', @@ -231,7 +233,7 @@ describe('ReconnectService', () => { }); it('should not dispatch syncActiveConversationMessages if conversationId does not exist', async () => { - routerMock.currentRoute.params.conversation_id = null; + routerMock.currentRoute.value.params.conversation_id = null; await reconnectService.fetchConversationMessagesOnReconnect(); expect(storeMock.dispatch).not.toHaveBeenCalledWith( 'syncActiveConversationMessages', @@ -305,7 +307,7 @@ describe('ReconnectService', () => { describe('setConversationLastMessageId', () => { it('should dispatch setConversationLastMessageId if conversationId exists', async () => { - routerMock.currentRoute.params.conversation_id = 1; + routerMock.currentRoute.value.params.conversation_id = 1; await reconnectService.setConversationLastMessageId(); expect(storeMock.dispatch).toHaveBeenCalledWith( 'setConversationLastMessageId', @@ -314,7 +316,7 @@ describe('ReconnectService', () => { }); it('should not dispatch setConversationLastMessageId if conversationId does not exist', async () => { - routerMock.currentRoute.params.conversation_id = null; + routerMock.currentRoute.value.params.conversation_id = null; await reconnectService.setConversationLastMessageId(); expect(storeMock.dispatch).not.toHaveBeenCalledWith( 'setConversationLastMessageId', diff --git a/app/javascript/dashboard/helper/specs/directives/resize.spec.js b/app/javascript/dashboard/helper/specs/directives/resize.spec.js deleted file mode 100644 index 18ef481d8..000000000 --- a/app/javascript/dashboard/helper/specs/directives/resize.spec.js +++ /dev/null @@ -1,78 +0,0 @@ -import resize from '../../directives/resize'; - -class ResizeObserverMock { - // eslint-disable-next-line class-methods-use-this - observe() {} - - // eslint-disable-next-line class-methods-use-this - unobserve() {} - - // eslint-disable-next-line class-methods-use-this - disconnect() {} -} - -describe('resize directive', () => { - let el; - let binding; - let observer; - - beforeEach(() => { - el = document.createElement('div'); - binding = { - value: vi.fn(), - }; - observer = { - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), - }; - window.ResizeObserver = ResizeObserverMock; - vi.spyOn(window, 'ResizeObserver').mockImplementation(() => observer); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('should create ResizeObserver on bind', () => { - resize.bind(el, binding); - - expect(ResizeObserver).toHaveBeenCalled(); - expect(observer.observe).toHaveBeenCalledWith(el); - }); - - it('should call callback on observer callback', () => { - el = document.createElement('div'); - binding = { - value: vi.fn(), - }; - - resize.bind(el, binding); - - const entries = [{ contentRect: { width: 100, height: 100 } }]; - const callback = binding.value; - callback(entries[0]); - - expect(binding.value).toHaveBeenCalledWith(entries[0]); - }); - - it('should destroy and recreate observer on update', () => { - resize.bind(el, binding); - - resize.update(el, { ...binding, oldValue: 'old' }); - - expect(observer.unobserve).toHaveBeenCalledWith(el); - expect(observer.disconnect).toHaveBeenCalled(); - expect(ResizeObserver).toHaveBeenCalledTimes(2); - expect(observer.observe).toHaveBeenCalledTimes(2); - }); - - it('should destroy observer on unbind', () => { - resize.bind(el, binding); - - resize.unbind(el); - - expect(observer.unobserve).toHaveBeenCalledWith(el); - expect(observer.disconnect).toHaveBeenCalled(); - }); -}); diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index eab5f68c5..a33ef3a1e 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -9,8 +9,8 @@ import { findNodeToInsertImage, setURLWithQueryAndSize, } from '../editorHelper'; -import { EditorState } from 'prosemirror-state'; -import { EditorView } from 'prosemirror-view'; +import { EditorState } from '@chatwoot/prosemirror-schema'; +import { EditorView } from '@chatwoot/prosemirror-schema'; import { Schema } from 'prosemirror-model'; // Define a basic ProseMirror schema diff --git a/app/javascript/dashboard/mixins/specs/fileUploadMixin.spec.js b/app/javascript/dashboard/mixins/specs/fileUploadMixin.spec.js index a3bd6c494..a873b1859 100644 --- a/app/javascript/dashboard/mixins/specs/fileUploadMixin.spec.js +++ b/app/javascript/dashboard/mixins/specs/fileUploadMixin.spec.js @@ -1,6 +1,8 @@ -import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; import { useAlert } from 'dashboard/composables'; import fileUploadMixin from 'dashboard/mixins/fileUploadMixin'; +import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; +import { reactive } from 'vue'; vi.mock('shared/helpers/FileHelper', () => ({ checkFileSizeLimit: vi.fn(), @@ -17,61 +19,80 @@ vi.mock('dashboard/composables', () => ({ })); describe('FileUploadMixin', () => { - let vm; + let wrapper; + let mockGlobalConfig; + let mockCurrentChat; + let mockCurrentUser; beforeEach(() => { - vm = new Vue(fileUploadMixin); - vm.isATwilioSMSChannel = false; - vm.globalConfig = { + mockGlobalConfig = reactive({ directUploadsEnabled: true, - }; - vm.accountId = 123; - vm.currentChat = { + }); + + mockCurrentChat = reactive({ id: 456, - }; - vm.currentUser = { + }); + + mockCurrentUser = reactive({ access_token: 'token', - }; - vm.$t = vi.fn(message => message); - vm.showAlert = vi.fn(); - vm.attachFile = vi.fn(); + }); + + wrapper = shallowMount({ + mixins: [fileUploadMixin], + data() { + return { + globalConfig: mockGlobalConfig, + currentChat: mockCurrentChat, + currentUser: mockCurrentUser, + isATwilioSMSChannel: false, + }; + }, + methods: { + attachFile: vi.fn(), + showAlert: vi.fn(), + $t: msg => msg, + }, + template: '
    ', + }); }); it('should call onDirectFileUpload when direct uploads are enabled', () => { - vm.onDirectFileUpload = vi.fn(); - vm.onFileUpload({}); - expect(vm.onDirectFileUpload).toHaveBeenCalledWith({}); + wrapper.vm.onDirectFileUpload = vi.fn(); + wrapper.vm.onFileUpload({}); + expect(wrapper.vm.onDirectFileUpload).toHaveBeenCalledWith({}); }); it('should call onIndirectFileUpload when direct uploads are disabled', () => { - vm.globalConfig.directUploadsEnabled = false; - vm.onIndirectFileUpload = vi.fn(); - vm.onFileUpload({}); - expect(vm.onIndirectFileUpload).toHaveBeenCalledWith({}); + wrapper.vm.globalConfig.directUploadsEnabled = false; + wrapper.vm.onIndirectFileUpload = vi.fn(); + wrapper.vm.onFileUpload({}); + expect(wrapper.vm.onIndirectFileUpload).toHaveBeenCalledWith({}); }); describe('onDirectFileUpload', () => { it('returns early if no file is provided', () => { - const returnValue = vm.onDirectFileUpload(null); + const returnValue = wrapper.vm.onDirectFileUpload(null); expect(returnValue).toBeUndefined(); }); it('shows an alert if the file size exceeds the maximum limit', () => { const fakeFile = { size: 999999999 }; - vm.onDirectFileUpload(fakeFile); + checkFileSizeLimit.mockReturnValue(false); // Mock exceeding file size + wrapper.vm.onDirectFileUpload(fakeFile); expect(useAlert).toHaveBeenCalledWith(expect.any(String)); }); }); describe('onIndirectFileUpload', () => { it('returns early if no file is provided', () => { - const returnValue = vm.onIndirectFileUpload(null); + const returnValue = wrapper.vm.onIndirectFileUpload(null); expect(returnValue).toBeUndefined(); }); it('shows an alert if the file size exceeds the maximum limit', () => { const fakeFile = { size: 999999999 }; - vm.onIndirectFileUpload(fakeFile); + checkFileSizeLimit.mockReturnValue(false); // Mock exceeding file size + wrapper.vm.onIndirectFileUpload(fakeFile); expect(useAlert).toHaveBeenCalledWith(expect.any(String)); }); }); diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/mixins/specs/portalMixin.spec.js b/app/javascript/dashboard/routes/dashboard/helpcenter/mixins/specs/portalMixin.spec.js index e43f4ce9b..0cac4f28b 100644 --- a/app/javascript/dashboard/routes/dashboard/helpcenter/mixins/specs/portalMixin.spec.js +++ b/app/javascript/dashboard/routes/dashboard/helpcenter/mixins/specs/portalMixin.spec.js @@ -1,16 +1,15 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import { createStore } from 'vuex'; +import { createRouter, createWebHistory } from 'vue-router'; import portalMixin from '../portalMixin'; -import Vuex from 'vuex'; -import VueRouter from 'vue-router'; -const localVue = createLocalVue(); -localVue.use(Vuex); -localVue.use(VueRouter); import ListAllArticles from '../../pages/portals/ListAllPortals.vue'; -const router = new VueRouter({ +// Create router instance +const router = createRouter({ + history: createWebHistory(), routes: [ { - path: ':portalSlug/:locale/articles', + path: '/:portalSlug/:locale/articles', // Add leading "/" name: 'list_all_locale_articles', component: ListAllArticles, }, @@ -30,18 +29,21 @@ describe('portalMixin', () => { render() {}, title: 'TestComponent', mixins: [portalMixin], - router, }; - store = new Vuex.Store({ getters }); - wrapper = shallowMount(Component, { store, localVue }); + store = createStore({ getters }); + wrapper = shallowMount(Component, { + global: { + plugins: [store, router], + }, + }); }); - it('return account id', () => { + it('returns account id', () => { expect(wrapper.vm.accountId).toBe(1); }); - it('returns article url', () => { - router.push({ + it('returns article url', async () => { + await router.push({ name: 'list_all_locale_articles', params: { portalSlug: 'fur-rent', locale: 'en' }, }); @@ -50,24 +52,24 @@ describe('portalMixin', () => { ); }); - it('returns portal locale', () => { - router.push({ + it('returns portal locale', async () => { + await router.push({ name: 'list_all_locale_articles', params: { portalSlug: 'fur-rent', locale: 'es' }, }); expect(wrapper.vm.portalSlug).toBe('fur-rent'); }); - it('returns portal slug', () => { - router.push({ + it('returns portal slug', async () => { + await router.push({ name: 'list_all_locale_articles', params: { portalSlug: 'campaign', locale: 'es' }, }); expect(wrapper.vm.portalSlug).toBe('campaign'); }); - it('returns locale name', () => { - router.push({ + it('returns locale name', async () => { + await router.push({ name: 'list_all_locale_articles', params: { portalSlug: 'fur-rent', locale: 'es' }, }); diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/articles/ArticleSettings.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/articles/ArticleSettings.vue index b8e3a8d45..7a014f4dd 100644 --- a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/articles/ArticleSettings.vue +++ b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/articles/ArticleSettings.vue @@ -128,6 +128,7 @@ export default {