Compare commits

...
Author SHA1 Message Date
Vishnu Narayanan ef19fcd8c0 fix: test switchlocale method directly instead of via http routing 2025-11-21 16:31:14 +05:30
Vishnu Narayanan d261dfd394 fix: shopify and locale spec 2025-11-21 16:28:03 +05:30
Vishnu Narayanan 1b9673b272 fix: test switchlocale method directly instead of via http routing 2025-11-21 16:21:05 +05:30
Sivin VargheseandGitHub 45fa697885 chore: Hide pagination for empty company list (#12920) 2025-11-21 12:23:01 +05:30
Sivin VargheseandGitHub 51fbf583b6 fix: Companies card pluralization issue (#12919) 2025-11-21 12:22:02 +05:30
Sivin VargheseandGitHub 38d6ee6dd2 chore: Save company list sorting preferences in UI settings (#12916)
# Pull Request Template

## Description

This PR saves the company list sorting preferences (field and order) in
the user’s UI settings. The sort state is initialized from the stored
preferences when the component mounts, defaulting to `-created_at` if
none exist.

Fixes
https://linear.app/chatwoot/issue/CW-5992/save-sort-filter-to-ui-settings

## Type of change
- [x] New feature (non-breaking change which adds functionality)


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-11-20 16:51:27 -08:00
Vishnu NarayananandGitHub a547c28c8d fix: reduce sidekiq memorymax to 1.2gb (#12915)
- reduce sidekiq memorymax to 1.2GB to ensure OOM restarts work on a 2GB
machine
2025-11-20 21:20:42 +05:30
5608f5a1a2 fix: Widget shows 'away' on initial load despite agents being online (#12869)
## Description

Fixes #12868

This PR fixes a Vue 3 reactivity bug that causes the widget to display
"We are away at the moment" on initial page load, even when agents are
online and the API correctly returns their availability.

## Problem

The widget welcome screen shows "We are away" on first render, only
updating to show correct agent status after navigating to the
conversation view and back. This misleads visitors into thinking no
agents are available.

**Reproduction:** Open website with widget in fresh incognito window,
click bubble immediately → shows "away" despite agents being online.

## Root Cause

Vue 3 reactivity chain breaks in the `useAvailability` composable:

**Before (broken):**
```javascript
// AvailabilityContainer.vue
const { isOnline } = useAvailability(props.agents); // Passes VALUE

// useAvailability.js  
const availableAgents = toRef(agents); // Creates ref from VALUE, doesn't track changes
```

When the API responds and updates the Vuex store, the parent component's
computed `props.agents` updates correctly, but the composable's
`toRef()` doesn't know about the change because it was created from a
static value, not a reactive source.

## Solution

**After (fixed):**
```javascript
// AvailabilityContainer.vue
const { isOnline } = useAvailability(toRef(props, 'agents')); // Passes REACTIVE REF

// useAvailability.js
const availableAgents = computed(() => unref(agents)); // Unwraps ref and tracks changes
```

Now when `props.agents` updates after the API response, the `computed()`
re-evaluates and all downstream reactive properties (`hasOnlineAgents`,
`isOnline`) update correctly.

## Testing

-  Initial page load shows correct agent status immediately
-  Status changes via WebSocket update correctly  
-  No configuration changes or workarounds needed
-  Tested with network monitoring (Puppeteer) confirming API returns
correct data

## Files Changed

1.
`app/javascript/widget/components/Availability/AvailabilityContainer.vue`
   - Pass `toRef(props, 'agents')` instead of `props.agents`

2. `app/javascript/widget/composables/useAvailability.js`
   - Use `computed(() => unref(agents))` instead of `toRef(agents)`
   - Added explanatory comments

## Related Issues

- #5918 - Similar symptoms, closed with workaround (business hours
toggle) rather than fixing root cause
- #5763 - Different issue (mobile app presence)

This is a genuine Vue 3 reactivity bug affecting all widgets,
independent of business hours configuration.

Co-authored-by: rcoenen <rcoenen@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-11-20 20:27:44 +05:30
Shivam MishraandGitHub da4110a495 feat: add retry loadWithRetry composable (#12873) 2025-11-20 19:40:20 +05:30
Vinay KeerthiandGitHub 9a2136caf1 fix: Change messages.source_id to text column (#12908)
## Summary
Changes `messages.source_id` from `string` (255 char limit) to `text`
(20,000 char limit) to support long email Message-ID headers.

## Changes
- Migration to change column type from string to text
- Added spec tests for source_id length validation

## Related
Fixes
https://linear.app/chatwoot/issue/CW-5961/activerecordrecordinvalid-validation-failed-source-is-too-long-maximum
2025-11-20 11:41:41 +05:30
Shivam MishraandGitHub a44192bbe7 feat: remove captain v2 from chatwoot internal list (#12903) 2025-11-19 17:49:47 +05:30
Vishnu NarayananandGitHub 5a5b30fe1e chore: increment CW version (#12902)
fixes https://github.com/chatwoot/chatwoot/issues/12889
2025-11-19 17:10:01 +05:30
Vishnu NarayananandGitHub 08b9134486 feat: speed up circleci and github actions (#12849)
# 🚀 Speed up CI/CD test execution with parallelization

## TL;DR

- **Problem**: CI tests took 36-42 minutes per commit, blocking
developer workflow
- **Solution**: Implemented 16-way parallelization + optimized slow
tests + fixed Docker builds
- **Impact**: **1,358 hours/month saved** (7.7 FTE) across GitHub
Actions + CircleCI
  - GitHub Actions tests: 36m → 7m (82% faster)
  - Backend tests: 28m → 4m (87% faster with 16-way parallelization)
  - CircleCI tests: 42m → 7m (83% faster)
  - Docker builds: 34m → 5m (85% faster)
- **Result**: 5-6x faster feedback loops, 100% success rate on recent
runs

---

## Problem
CI test runs were taking **36-42 minutes per commit push** (GitHub
Actions: 36m avg, CircleCI: 42m P95), creating a significant bottleneck
in the development workflow.

## Solution
This PR comprehensively restructures both CI test pipelines to leverage
16-way parallelization and optimize test execution, reducing test
runtime from **36-42 minutes to ~7 minutes** - an **82% improvement**.

---

## 📊 Real Performance Data (Both CI Systems)

### GitHub Actions

#### Before (develop branch - 5 recent runs)
```
Individual runs: 35m 29s | 36m 1s | 40m 0s | 36m 4s | 34m 18s
Average: 36m 22s
```

#### After (feat/speed_up_ci branch - 9 successful runs)
```
Individual runs: 6m 39s | 7m 2s | 6m 53s | 6m 26s | 6m 52s | 6m 42s | 6m 45s | 6m 40s | 6m 37s
Average: 6m 44s
Range: 6m 26s - 7m 2s
```

**Improvement**:  **81.5% faster** (29m 38s saved per run)


#### Backend Tests Specific Impact
With 16-way parallelization, backend tests show dramatic improvement:
- **Before**: 27m 52s (sequential execution)
- **After**: 3m 44s (longest of 16 parallel runners)
  - Average across runners: 2m 30s
  - Range: 1m 52s - 3m 44s
- **Improvement**:  **86.6% faster** (24m 8s saved)
---

### CircleCI

#### Before (develop branch - CircleCI Insights)
```
Duration (P95): 41m 44s
Runs: 70 (last 30 days)
Success Rate: 84%
```

#### After (feat/speed_up_ci branch - Last 2 pipeline runs)
```
Run 1 (1h ago): 7m 7s
  ├─ lint: 4m 12s
  ├─ frontend-tests: 5m 36s
  ├─ backend-tests: 6m 23s
  ├─ coverage: 20s
  └─ build: 1s

Run 2 (2h ago): 7m 21s
  ├─ lint: 3m 47s
  ├─ frontend-tests: 5m 4s
  ├─ backend-tests: 6m 33s
  ├─ coverage: 19s
  └─ build: 1s

Average: 7m 14s
Success Rate: 100% 
```

**Improvement**:  **82.7% faster** (34m 30s saved per run)

---

## 🐳 Related Work: Docker Build Optimization

As part of the broader CI/CD optimization effort, Docker build
performance was improved separately in **PR #12859**.

### Docker Build Fix (Merged Separately)
**Problem**: Multi-architecture Docker builds (amd64/arm64) were taking
~34 minutes due to cache thrashing

**Solution**: Added separate cache scopes per platform in
`.github/workflows/test_docker_build.yml`:
```yaml
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
```

**Results** (measured from November 2025 data):
- **Before**: 34.2 minutes/run average (15,547 minutes across 454 runs)
- **After**: 5 minutes/run
- **Improvement**: 85% faster, 29.2 minutes saved per run
- **Frequency**: 25.2 runs/day
- **Monthly savings**: **369 hours** (46 developer-days)

This prevents different architectures from invalidating each other's
caches and contributes 27% of total CI/CD time savings.

---

## 🎯 Key Findings

### Both CI Systems Now Perform Similarly
- **CircleCI**: 7m 14s average
- **GitHub Actions**: 6m 44s average
- **Difference**: Only 30 seconds apart (remarkably consistent!)

### Combined Performance
- **Average improvement across both systems**: **82.1% faster**
- **Time saved per commit**: ~32 minutes
- **Developer feedback loop**: 36-42 minutes → ~7 minutes

### Success Rate Improvement
- **CircleCI**: 84% → 100% (on feat/speed_up_ci branch)
- **GitHub Actions**: 100% (all 9 recent runs successful)
- Fixed all test isolation issues that caused intermittent failures

### Impact at Scale (Based on Real November 2025 Data)
- **CI runs per day**: **30.8 average** for tests, **25.2** for Docker
builds
  - Measured from GitHub Actions Usage Metrics (18 days)
  - Weekdays: 38-54 runs/day
  - Peak: up to 68 runs in a single day
- **This PR (test suite only)**:
  - **Daily time saved**: **15.3 hours** (GitHub Actions + CircleCI)
- **Monthly time saved**: **458 hours** (57 developer-days) on GitHub
Actions
  - Additional **531 hours** (66 developer-days) on CircleCI
- **Combined with Docker optimization** (PR #12859): **1,358
hours/month** (see Summary)
- **Developer experience**: 5-6x faster iteration cycles

---

## Code Changes

### 1. **Backend Test Parallelization (16x)**
Both CI systems now use 16-way parallelization with identical
round-robin test distribution:

```bash
# Distribute tests evenly across 16 runners
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
for i in "${!SPEC_FILES[@]}"; do
  if [ $(( i % 16 )) -eq $RUNNER_INDEX ]; then
    TESTS="$TESTS ${SPEC_FILES[$i]}"
  fi
done
```

**Why round-robin over timing-based?**
- CircleCI's timing-based splitting grouped similar tests together
- This caused race conditions with OAuth callback tests (Linear,
Shopify, Notion)
- Round-robin ensures even distribution and better test isolation
- Both CI systems now behave identically

### 2. **Frontend Test Optimization**
Enabled Vitest thread parallelization in `vite.config.ts`:

```typescript
pool: 'threads',
poolOptions: {
  threads: {
    singleThread: false,
  },
},
```

### 3. **CI Architecture Restructuring**
Split monolithic CI jobs into parallel stages:
- **Lint** (backend + frontend) - runs independently for fast feedback
- **Frontend tests** - runs in parallel with backend
- **Backend tests** - 16-way parallelized across runners
- **Coverage** - aggregates results from test jobs
- **Build** (CircleCI only) - final job for GitHub status check
compatibility

### 4. **Critical Test Optimization**
**report_builder_spec.rb**: Changed `before` to `before_all`
- Reduced execution time from **19 minutes to 1.2 minutes** (16x
speedup)
- Setup now runs once instead of 21 times
- Single biggest performance improvement after parallelization

---

## Test Stability Fixes (10 spec files)

Parallelization exposed latent test isolation issues that were fixed:

### Object Identity Comparisons (6 files)
Tests were comparing Ruby object instances instead of IDs:
- `spec/models/integrations/hook_spec.rb` - Use `.pluck(:id)` for
comparisons
- `spec/enterprise/models/captain/scenario_spec.rb` - Compare IDs
instead of objects
- `spec/models/notification_spec.rb` - Compare IDs for sort order
validation
- `spec/models/account_spec.rb` - Compare IDs in scope queries
- `spec/services/widget/token_service_spec.rb` - Compare class names
instead of class objects
- `spec/models/concerns/avatarable_shared.rb` - Use `respond_to` checks
for ActiveStorage

### Database Query Caching
- `spec/jobs/delete_object_job_spec.rb` - Added `.reload` to force fresh
database queries

### Test Expectations Timing
- `spec/jobs/mutex_application_job_spec.rb` - Removed flaky unlock
expectation after error block
  - Related to original PR #8770
- Expectation after error block never executes in parallel environments

### Timezone Handling
- `spec/mailers/account_notification_mailer_spec.rb` - Fixed date
parsing at timezone boundaries
  - Changed test time from 23:59:59Z to 12:00:00Z

### Test Setup
- `spec/builders/v2/report_builder_spec.rb` - Optimized with
`before_all`

---

##  CircleCI GitHub Integration Fix

### Problem
GitHub PR checks were stuck on "Waiting for status" even when all
CircleCI jobs passed. GitHub was expecting a job named `build` but the
workflow only had a workflow named "build".

### Solution
Added an explicit `build` job that runs after all other jobs:

```yaml
build:
  steps:
    - run:
        name: Legacy build aggregator
        command: echo "All main jobs passed"
  requires:
    - lint
    - coverage
```

This ensures GitHub's required status checks work correctly.


---

##  Testing & Validation

-  **GitHub Actions**: 9 successful runs, consistent 6m 26s - 7m 2s
runtime
-  **CircleCI**: 2 successful runs, consistent 7m 7s - 7m 21s runtime
-  Both CI systems produce identical, consistent results
-  GitHub PR status checks complete correctly
-  Success rate improved from 84% to 100% (recent runs)
-  No test regressions introduced
-  All flaky tests fixed (callback controllers, mutex jobs, etc.)

---

## 🎉 Summary

This PR delivers an **82% improvement** in test execution time across
both CI systems:

- **GitHub Actions tests**: 36m → 7m (81.5% faster)
- Backend tests specifically: 28m → 4m (86.6% faster)
- **CircleCI tests**: 42m → 7m (82.7% faster)
- **Developer feedback loop**: 5-6x faster
- **Test stability**: 84% → 100% success rate

### 📊 Total CI/CD Impact (All Optimizations)

Based on real November 2025 data, combining this PR with Docker build
optimization (PR #12859):

**Monthly Time Savings**: **1,358 hours/month** = **170
developer-days/month** = **7.7 FTE**

| System | Runs/Day | Before | After | Savings | Monthly Impact |
|--------|----------|---------|--------|---------|----------------|
| **GitHub Actions Tests** | 30.8 | 36.5m | 6.7m | 29.8m/run | 458 hrs
(34%) |
| **GitHub Actions Docker** | 25.2 | 34.2m | 5.0m | 29.2m/run | 369 hrs
(27%) |
| **CircleCI Tests** | 30.8 | 41.7m | 7.2m | 34.5m/run | 531 hrs (39%) |

*Data source: GitHub Actions Usage Metrics (November 2025, 18 days),
CircleCI Insights (30 days)*

The combined optimizations save the equivalent of **nearly 8 full-time
developers** worth of CI waiting time every month, significantly
improving developer velocity and reducing CI costs. All test isolation
issues exposed by parallelization have been fixed, ensuring reliable and
consistent results across both CI platforms.

woot woot !!!

---------
2025-11-19 15:32:48 +05:30
Sojan Jose 7874a6a3dd Merge branch 'release/4.8.0' into develop 2025-11-18 18:41:59 -08:00
32 changed files with 516 additions and 144 deletions
+196 -49
View File
@@ -3,6 +3,7 @@ orbs:
node: circleci/node@6.1.0
qlty-orb: qltysh/qlty-orb@0.0
# Shared defaults for setup steps
defaults: &defaults
working_directory: ~/build
machine:
@@ -12,10 +13,106 @@ defaults: &defaults
RAILS_LOG_TO_STDOUT: false
COVERAGE: true
LOG_LEVEL: warn
parallelism: 4
jobs:
build:
# Separate job for linting (no parallelism needed)
lint:
<<: *defaults
steps:
- checkout
# Install minimal system dependencies for linting
- run:
name: Install System Dependencies
command: |
sudo apt-get update
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
libpq-dev \
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.4.4
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.4.4"
rvm use 3.4.4 --default
gem install bundler -v 2.5.16
- run:
name: Install Application Dependencies
command: |
source ~/.rvm/scripts/rvm
bundle install
- node/install:
node-version: '23.7'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
# Swagger verification
- run:
name: Verify swagger API specification
command: |
bundle exec rake swagger:build
if [[ `git status swagger/swagger.json --porcelain` ]]
then
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
exit 1
fi
mkdir -p ~/tmp
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
# 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 --parallel
# ESLint linting
- run:
name: eslint
command: pnpm run eslint
# Separate job for frontend tests
frontend-tests:
<<: *defaults
steps:
- checkout
@@ -25,8 +122,38 @@ jobs:
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
- run: node --version
- run: pnpm --version
- run:
name: Run frontend tests (with coverage)
command: pnpm run test:coverage
- run:
name: Move coverage files if they exist
command: |
if [ -d "coverage" ]; then
mkdir -p ~/build/coverage
cp -r coverage ~/build/coverage/frontend || true
fi
when: always
- persist_to_workspace:
root: ~/build
paths:
- coverage
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 16
steps:
- checkout
- node/install:
node-version: '23.7'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
- run:
name: Add PostgreSQL repository and update
command: |
@@ -91,20 +218,6 @@ jobs:
source ~/.rvm/scripts/rvm
bundle install
# Swagger verification
- run:
name: Verify swagger API specification
command: |
bundle exec rake swagger:build
if [[ `git status swagger/swagger.json --porcelain` ]]
then
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
exit 1
fi
mkdir -p ~/tmp
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
# Configure environment and database
- run:
name: Database Setup and Configure Environment Variables
@@ -127,57 +240,91 @@ jobs:
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
# ESLint linting
- run:
name: eslint
command: pnpm run eslint
- run:
name: Run frontend tests (with coverage)
command: |
mkdir -p ~/build/coverage/frontend
pnpm run test:coverage
# Run backend tests
# Run backend tests (parallelized)
- run:
name: Run backend tests
command: |
mkdir -p ~/tmp/test-results/rspec
mkdir -p ~/tmp/test-artifacts
mkdir -p ~/build/coverage/backend
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
# Use round-robin distribution (same as GitHub Actions) for better test isolation
# This prevents tests with similar timing from being grouped on the same runner
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
TESTS=""
for i in "${!SPEC_FILES[@]}"; do
if [ $(( i % $CIRCLE_NODE_TOTAL )) -eq $CIRCLE_NODE_INDEX ]; then
TESTS="$TESTS ${SPEC_FILES[$i]}"
fi
done
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
--format RspecJunitFormatter \
--out ~/tmp/test-results/rspec.xml \
-- ${TESTFILES}
-- $TESTS
no_output_timeout: 30m
# Qlty coverage publish
- qlty-orb/coverage_publish:
files: |
coverage/coverage.json
coverage/lcov.info
# Store test results for better splitting in future runs
- store_test_results:
path: ~/tmp/test-results
- run:
name: List coverage directory contents
name: Move coverage files if they exist
command: |
ls -R ~/build/coverage
if [ -d "coverage" ]; then
mkdir -p ~/build/coverage
cp -r coverage ~/build/coverage/backend || true
fi
when: always
- persist_to_workspace:
root: ~/build
paths:
- coverage
# Collect coverage from all jobs
coverage:
<<: *defaults
steps:
- checkout
- attach_workspace:
at: ~/build
# Qlty coverage publish
- qlty-orb/coverage_publish:
files: |
coverage/frontend/lcov.info
- run:
name: List coverage directory contents
command: |
ls -R ~/build/coverage || echo "No coverage directory"
- store_artifacts:
path: coverage
destination: coverage
build:
<<: *defaults
steps:
- run:
name: Legacy build aggregator
command: |
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
workflows:
version: 2
build:
jobs:
- lint
- frontend-tests
- backend-tests
- coverage:
requires:
- frontend-tests
- backend-tests
- build:
requires:
- lint
- coverage
+79 -15
View File
@@ -1,4 +1,6 @@
name: Run Chatwoot CE spec
permissions:
contents: read
on:
push:
branches:
@@ -8,11 +10,58 @@ on:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-22.04
# Separate linting jobs for faster feedback
lint-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Run Rubocop
run: bundle exec rubocop --parallel
lint-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
- name: Run ESLint
run: pnpm run eslint
# Frontend tests run in parallel with backend
frontend-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
- name: Run frontend tests
run: pnpm run test:coverage
# Backend tests with parallelization
backend-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ci_node_total: [16]
ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
services:
postgres:
image: pgvector/pgvector:pg15
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ''
@@ -20,8 +69,6 @@ jobs:
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
# tmpfs makes DB faster by using RAM
options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready
@@ -29,7 +76,7 @@ jobs:
--health-timeout 5s
--health-retries 5
redis:
image: redis
image: redis:alpine
ports:
- 6379:6379
options: --entrypoint redis-server
@@ -43,7 +90,7 @@ jobs:
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
bundler-cache: true
- uses: actions/setup-node@v4
with:
@@ -64,19 +111,36 @@ jobs:
- name: Seed database
run: bundle exec rake db:schema:load
- name: Run frontend tests
run: pnpm run test:coverage
# Run rails tests
- name: Run backend tests
- name: Run backend tests (parallelized)
run: |
bundle exec rspec --profile=10 --format documentation
# Get all spec files and split them using round-robin distribution
# This ensures slow tests are distributed evenly across all nodes
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
TESTS=""
for i in "${!SPEC_FILES[@]}"; do
# Assign spec to this node if: index % total == node_index
if [ $(( i % ${{ matrix.ci_node_total }} )) -eq ${{ matrix.ci_node_index }} ]; then
TESTS="$TESTS ${SPEC_FILES[$i]}"
fi
done
if [ -n "$TESTS" ]; then
bundle exec rspec --profile=10 --format progress --format json --out tmp/rspec_results.json $TESTS
fi
env:
NODE_OPTIONS: --openssl-legacy-provider
- name: Upload rails log folder
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: rails-log-folder
name: rspec-results-${{ matrix.ci_node_index }}
path: tmp/rspec_results.json
- name: Upload rails log folder
uses: actions/upload-artifact@v4
if: failure()
with:
name: rails-log-folder-${{ matrix.ci_node_index }}
path: log
+1 -1
View File
@@ -1 +1 @@
4.4.0
4.8.0
@@ -79,7 +79,7 @@ const formattedUpdatedAt = computed(() => {
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
>
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
{{ t('COMPANIES.CONTACTS_COUNT', { count: contactsCount }) }}
{{ t('COMPANIES.CONTACTS_COUNT', { n: contactsCount }) }}
</span>
</div>
<span
@@ -9,6 +9,7 @@ defineProps({
totalItems: { type: Number, default: 100 },
activeSort: { type: String, default: 'name' },
activeOrdering: { type: String, default: '' },
showPaginationFooter: { type: Boolean, default: true },
});
const emit = defineEmits(['update:currentPage', 'update:sort', 'search']);
@@ -36,7 +37,7 @@ const updateCurrentPage = page => {
<slot name="default" />
</div>
</main>
<footer class="sticky bottom-0 z-0 px-4 pb-4">
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
<PaginationFooter
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
@@ -1,7 +1,8 @@
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
import BaseBubble from './Base.vue';
import Button from 'next/button/Button.vue';
import Icon from 'next/icon/Icon.vue';
@@ -11,7 +12,6 @@ import { downloadFile } from '@chatwoot/utils';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
const emit = defineEmits(['error']);
const { t } = useI18n();
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
@@ -20,14 +20,16 @@ const attachment = computed(() => {
return attachments.value[0];
});
const hasError = ref(false);
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry();
const showGallery = ref(false);
const isDownloading = ref(false);
const handleError = () => {
hasError.value = true;
emit('error');
};
onMounted(() => {
if (attachment.value?.dataUrl) {
loadWithRetry(attachment.value.dataUrl);
}
});
const downloadAttachment = async () => {
const { fileType, dataUrl, extension } = attachment.value;
@@ -40,6 +42,10 @@ const downloadAttachment = async () => {
isDownloading.value = false;
}
};
const handleImageError = () => {
hasError.value = true;
};
</script>
<template>
@@ -54,14 +60,12 @@ const downloadAttachment = async () => {
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
</p>
</div>
<div v-else class="relative group rounded-lg overflow-hidden">
<div v-else-if="isLoaded" class="relative group rounded-lg overflow-hidden">
<img
class="skip-context-menu"
:src="attachment.dataUrl"
:width="attachment.width"
:height="attachment.height"
@click="onClick"
@error="handleError"
/>
<div
class="inset-0 p-2 pointer-events-none absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex"
@@ -86,7 +90,7 @@ const downloadAttachment = async () => {
v-model:show="showGallery"
:attachment="useSnakeCase(attachment)"
:all-attachments="filteredCurrentChatAttachments"
@error="handleError"
@error="handleImageError"
@close="() => (showGallery = false)"
/>
</template>
@@ -0,0 +1,57 @@
import { ref } from 'vue';
export const useLoadWithRetry = (config = {}) => {
const maxRetry = config.max_retry || 3;
const backoff = config.backoff || 1000;
const isLoaded = ref(false);
const hasError = ref(false);
const loadWithRetry = async url => {
const attemptLoad = () => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
isLoaded.value = true;
hasError.value = false;
resolve();
};
img.onerror = () => {
reject(new Error('Failed to load image'));
};
img.src = url;
});
};
const sleep = ms => {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
};
const retry = async (attempt = 0) => {
try {
await attemptLoad();
} catch (error) {
if (attempt + 1 >= maxRetry) {
hasError.value = true;
isLoaded.value = false;
return;
}
await sleep(backoff * (attempt + 1));
await retry(attempt + 1);
}
};
await retry();
};
return {
isLoaded,
hasError,
loadWithRetry,
};
};
@@ -19,7 +19,7 @@
"SEARCH_PLACEHOLDER": "Search companies...",
"LOADING": "Loading companies...",
"UNNAMED": "Unnamed Company",
"CONTACTS_COUNT": "{count} contacts",
"CONTACTS_COUNT": "{n} contact | {n} contacts",
"EMPTY_STATE": {
"TITLE": "No companies found"
}
@@ -1,14 +1,16 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, reactive } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { debounce } from '@chatwoot/utils';
import CompaniesListLayout from 'dashboard/components-next/Companies/CompaniesListLayout.vue';
import CompaniesCard from 'dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue';
const DEFAULT_SORT_FIELD = 'created_at';
const DEBOUNCE_DELAY = 300;
const store = useStore();
@@ -16,19 +18,33 @@ const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { updateUISettings, uiSettings } = useUISettings();
const searchQuery = computed(() => route.query?.search || '');
const searchValue = ref(searchQuery.value);
const pageNumber = computed(() => Number(route.query?.page) || 1);
const activeSort = computed(() => {
const sortParam = route.query?.sort || 'name';
return sortParam.startsWith('-') ? sortParam.slice(1) : sortParam;
const parseSortSettings = (sortString = '') => {
const hasDescending = sortString.startsWith('-');
const sortField = hasDescending ? sortString.slice(1) : sortString;
return {
sort: sortField || DEFAULT_SORT_FIELD,
order: hasDescending ? '-' : '',
};
};
const { companies_sort_by: companySortBy = `-${DEFAULT_SORT_FIELD}` } =
uiSettings.value ?? {};
const { sort: initialSort, order: initialOrder } =
parseSortSettings(companySortBy);
const sortState = reactive({
activeSort: initialSort,
activeOrdering: initialOrder,
});
const activeOrdering = computed(() => {
const sortParam = route.query?.sort || 'name';
return sortParam.startsWith('-') ? '-' : '';
});
const activeSort = computed(() => sortState.activeSort);
const activeOrdering = computed(() => sortState.activeOrdering);
const companies = useMapGetter('companies/getCompaniesList');
const meta = useMapGetter('companies/getMeta');
@@ -36,11 +52,10 @@ const uiFlags = useMapGetter('companies/getUIFlags');
const isFetchingList = computed(() => uiFlags.value.fetchingList);
const sortParam = computed(() => {
return activeOrdering.value === '-'
? `-${activeSort.value}`
: activeSort.value;
});
const buildSortAttr = () =>
`${sortState.activeOrdering}${sortState.activeSort}`;
const sortParam = computed(() => buildSortAttr());
const updateURLParams = (page, search = '', sort = '') => {
const query = {
@@ -96,9 +111,14 @@ const onPageChange = page => {
fetchCompanies(page, searchValue.value, sortParam.value);
};
const handleSort = ({ sort, order }) => {
const newSortParam = order === '-' ? `-${sort}` : sort;
fetchCompanies(1, searchValue.value, newSortParam);
const handleSort = async ({ sort, order }) => {
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
await updateUISettings({
companies_sort_by: buildSortAttr(),
});
fetchCompanies(1, searchValue.value, buildSortAttr());
};
onMounted(() => {
@@ -116,6 +136,7 @@ onMounted(() => {
:active-sort="activeSort"
:active-ordering="activeOrdering"
:is-fetching-list="isFetchingList"
:show-pagination-footer="!!companies.length"
@update:current-page="onPageChange"
@update:sort="handleSort"
@search="onSearch"
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, toRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import GroupedAvatars from 'widget/components/GroupedAvatars.vue';
@@ -30,13 +30,15 @@ const { t } = useI18n();
const availableMessage = useMapGetter('appConfig/getAvailableMessage');
const unavailableMessage = useMapGetter('appConfig/getUnavailableMessage');
// Pass toRef(props, 'agents') instead of props.agents to maintain reactivity
// when the parent component's agents prop updates (e.g., after API response)
const {
currentTime,
hasOnlineAgents,
isOnline,
inboxConfig,
isInWorkingHours,
} = useAvailability(props.agents);
} = useAvailability(toRef(props, 'agents'));
const workingHours = computed(() => inboxConfig.value.workingHours || []);
const workingHoursEnabled = computed(
@@ -1,4 +1,4 @@
import { computed, toRef } from 'vue';
import { computed, unref } from 'vue';
import {
isOnline as checkIsOnline,
isInWorkingHours as checkInWorkingHours,
@@ -14,7 +14,11 @@ const DEFAULT_REPLY_TIME = 'in_a_few_minutes';
* @returns {Object} Availability utilities and computed properties
*/
export function useAvailability(agents = []) {
const availableAgents = toRef(agents);
// Now receives toRef(props, 'agents') from caller, which maintains reactivity.
// Use unref() inside computed to unwrap the ref value properly.
// This ensures availableAgents updates when the parent's agents prop changes
// (e.g., after API response updates the Vuex store).
const availableAgents = computed(() => unref(agents));
const channelConfig = computed(() => window.chatwootWebChannel || {});
+1 -1
View File
@@ -20,7 +20,7 @@
# conversation_id :integer not null
# inbox_id :integer not null
# sender_id :bigint
# source_id :string
# source_id :text
#
# Indexes
#
-1
View File
@@ -180,7 +180,6 @@
display_name: Captain V2
enabled: false
premium: true
chatwoot_internal: true
- name: whatsapp_embedded_signup
display_name: WhatsApp Embedded Signup
enabled: false
@@ -0,0 +1,9 @@
class ChangeMessagesSourceIdToText < ActiveRecord::Migration[7.1]
def up
change_column :messages, :source_id, :text
end
def down
change_column :messages, :source_id, :string
end
end
+2 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2025_11_14_173609) do
ActiveRecord::Schema[7.1].define(version: 2025_11_19_161025) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -954,7 +954,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_11_14_173609) do
t.datetime "updated_at", precision: nil, null: false
t.boolean "private", default: false, null: false
t.integer "status", default: 0
t.string "source_id"
t.text "source_id"
t.integer "content_type", default: 0, null: false
t.json "content_attributes", default: {}
t.string "sender_type"
+1 -1
View File
@@ -16,7 +16,7 @@ KillMode=mixed
StandardInput=null
SyslogIdentifier=%p
MemoryMax=1.5G
MemoryMax=1.2G
MemoryHigh=infinity
MemorySwapMax=0
OOMPolicy=stop
+3 -1
View File
@@ -7,7 +7,9 @@ describe V2::ReportBuilder do
let_it_be(:label_2) { create(:label, title: 'Label_2', account: account) }
describe '#timeseries' do
before do
# Use before_all to share expensive setup across all tests in this describe block
# This runs once instead of 21 times, dramatically speeding up the suite
before_all do
travel_to(Time.zone.today) do
user = create(:user, account: account)
inbox = create(:inbox, account: account)
@@ -94,10 +94,8 @@ RSpec.describe Shopify::CallbacksController, type: :request do
context 'when state parameter is invalid' do
before do
controller = described_class.new
allow(controller).to receive(:verify_shopify_token).with(state).and_return(nil)
allow(controller).to receive(:account).and_return(nil)
allow(described_class).to receive(:new).and_return(controller)
allow_any_instance_of(Shopify::CallbacksController).to receive(:verify_shopify_token).and_return(nil)
allow_any_instance_of(Shopify::CallbacksController).to receive(:account).and_return(nil)
end
it 'redirects to the frontend URL with error' do
@@ -23,8 +23,8 @@ RSpec.describe Captain::Scenario, type: :model do
enabled_scenario = create(:captain_scenario, assistant: assistant, account: account, enabled: true)
disabled_scenario = create(:captain_scenario, assistant: assistant, account: account, enabled: false)
expect(described_class.enabled).to include(enabled_scenario)
expect(described_class.enabled).not_to include(disabled_scenario)
expect(described_class.enabled.pluck(:id)).to include(enabled_scenario.id)
expect(described_class.enabled.pluck(:id)).not_to include(disabled_scenario.id)
end
end
end
+10 -8
View File
@@ -24,11 +24,12 @@ RSpec.describe DeleteObjectJob, type: :job do
described_class.perform_now(inbox)
expect(Conversation.where(id: conv_ids)).to be_empty
expect(ContactInbox.where(id: ci_ids)).to be_empty
expect(ReportingEvent.where(id: re_ids)).to be_empty
# Reload associations to ensure database state is current
expect(Conversation.where(id: conv_ids).reload).to be_empty
expect(ContactInbox.where(id: ci_ids).reload).to be_empty
expect(ReportingEvent.where(id: re_ids).reload).to be_empty
# Contacts should not be deleted for inbox destroy
expect(Contact.where(id: contact_ids)).not_to be_empty
expect(Contact.where(id: contact_ids).reload).not_to be_empty
expect { inbox.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
@@ -53,10 +54,11 @@ RSpec.describe DeleteObjectJob, type: :job do
described_class.perform_now(account)
expect(Conversation.where(id: conv_ids)).to be_empty
expect(Contact.where(id: contact_ids)).to be_empty
expect(Inbox.where(id: inbox_ids)).to be_empty
expect(ReportingEvent.where(id: re_ids)).to be_empty
# Reload associations to ensure database state is current
expect(Conversation.where(id: conv_ids).reload).to be_empty
expect(Contact.where(id: contact_ids).reload).to be_empty
expect(Inbox.where(id: inbox_ids).reload).to be_empty
expect(ReportingEvent.where(id: re_ids).reload).to be_empty
expect { account.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
-1
View File
@@ -33,7 +33,6 @@ RSpec.describe MutexApplicationJob do
# Do nothing
end
end.to raise_error(MutexApplicationJob::LockAcquisitionError)
expect(lock_manager).not_to receive(:unlock)
end
it 'raises StandardError if it execution raises it' do
@@ -28,7 +28,7 @@ RSpec.describe AdministratorNotifications::AccountNotificationMailer do
describe '#format_deletion_date' do
it 'formats a valid date string' do
date_str = '2024-12-31T23:59:59Z'
date_str = '2024-12-31T12:00:00Z'
formatted = described_class.new.send(:format_deletion_date, date_str)
expect(formatted).to eq('December 31, 2024')
end
+2 -2
View File
@@ -203,12 +203,12 @@ RSpec.describe Account do
context 'when using with_auto_resolve scope' do
it 'finds accounts with auto_resolve_after set' do
account.update(auto_resolve_after: 40 * 24 * 60)
expect(described_class.with_auto_resolve).to include(account)
expect(described_class.with_auto_resolve.pluck(:id)).to include(account.id)
end
it 'does not find accounts without auto_resolve_after' do
account.update(auto_resolve_after: nil)
expect(described_class.with_auto_resolve).not_to include(account)
expect(described_class.with_auto_resolve.pluck(:id)).not_to include(account.id)
end
end
end
+4 -1
View File
@@ -3,7 +3,10 @@ require 'rails_helper'
shared_examples_for 'avatarable' do
let(:avatarable) { create(described_class.to_s.underscore) }
it { is_expected.to have_one_attached(:avatar) }
it 'has avatar attachment defined' do
expect(avatarable).to respond_to(:avatar)
expect(avatarable.avatar).to respond_to(:attach)
end
it 'add avatar_url method' do
expect(avatarable.respond_to?(:avatar_url)).to be true
+7 -6
View File
@@ -81,14 +81,15 @@ RSpec.describe 'SwitchLocale Concern', type: :controller do
end
describe '#switch_locale_using_account_locale' do
before do
routes.draw { get 'account_locale' => 'anonymous#account_locale' }
end
it 'sets locale from account' do
controller.instance_variable_set(:@current_account, account)
get :account_locale
expect(response.body).to eq('es')
result = nil
controller.send(:switch_locale_using_account_locale) do
result = I18n.locale.to_s
end
expect(result).to eq('es')
end
end
end
+4 -4
View File
@@ -68,13 +68,13 @@ RSpec.describe Integrations::Hook do
end
it 'returns account hooks' do
expect(described_class.account_hooks).to include(account_hook)
expect(described_class.account_hooks).not_to include(inbox_hook)
expect(described_class.account_hooks.pluck(:id)).to include(account_hook.id)
expect(described_class.account_hooks.pluck(:id)).not_to include(inbox_hook.id)
end
it 'returns inbox hooks' do
expect(described_class.inbox_hooks).to include(inbox_hook)
expect(described_class.inbox_hooks).not_to include(account_hook)
expect(described_class.inbox_hooks.pluck(:id)).to include(inbox_hook.id)
expect(described_class.inbox_hooks.pluck(:id)).not_to include(account_hook.id)
end
end
+42
View File
@@ -45,6 +45,48 @@ RSpec.describe Message do
end
end
end
context 'when it validates source_id length' do
it 'valid when source_id is within text limit (20000 chars)' do
long_source_id = 'a' * 10_000
message.source_id = long_source_id
expect(message.valid?).to be true
end
it 'valid when source_id is exactly 20000 characters' do
long_source_id = 'a' * 20_000
message.source_id = long_source_id
expect(message.valid?).to be true
end
it 'invalid when source_id exceeds text limit (20000 chars)' do
long_source_id = 'a' * 20_001
message.source_id = long_source_id
message.valid?
expect(message.errors[:source_id]).to include('is too long (maximum is 20000 characters)')
end
it 'handles long email Message-ID headers correctly' do
# Simulate a long Message-ID like some email systems generate
long_message_id = "msg-#{SecureRandom.hex(240)}@verylongdomainname.example.com"[0...500]
message.source_id = long_message_id
message.content_type = 'incoming_email'
expect(message.valid?).to be true
expect(message.source_id.length).to eq(500)
end
it 'allows nil source_id' do
message.source_id = nil
expect(message.valid?).to be true
end
it 'allows empty string source_id' do
message.source_id = ''
expect(message.valid?).to be true
end
end
end
describe 'concerns' do
+2 -2
View File
@@ -16,8 +16,8 @@ RSpec.describe Notification do
create(:notification)
notification3 = create(:notification)
expect(described_class.all.first).to eq notification1
expect(described_class.all.last).to eq notification3
expect(described_class.all.first.id).to eq notification1.id
expect(described_class.all.last.id).to eq notification3.id
end
end
@@ -149,7 +149,7 @@ RSpec.describe Crm::Leadsquared::Api::LeadClient do
it 'raises ApiError' do
expect { client.create_or_update_lead(lead_data) }
.to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError)
.to(raise_error { |error| expect(error.class.name).to eq('Crm::Leadsquared::Api::BaseClient::ApiError') })
end
end
@@ -223,7 +223,7 @@ RSpec.describe Crm::Leadsquared::Api::LeadClient do
it 'raises ApiError' do
expect { client.update_lead(lead_data, lead_id) }
.to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError)
.to(raise_error { |error| expect(error.class.name).to eq('Crm::Leadsquared::Api::BaseClient::ApiError') })
end
end
end
@@ -108,6 +108,13 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
system_message
end
def formatted_line_for(msg, hook_for_tz)
tz = Time.find_zone(hook_for_tz.settings['timezone']) || Time.zone
ts = msg.created_at.in_time_zone(tz).strftime('%Y-%m-%d %H:%M')
sender = msg.sender&.name.presence || (msg.sender.present? ? "#{msg.sender_type} #{msg.sender_id}" : 'System')
"[#{ts}] #{sender}: #{msg.content.presence || I18n.t('crm.no_content')}"
end
it 'generates transcript with messages in reverse chronological order' do
result = described_class.map_transcript_activity(hook, conversation)
@@ -115,13 +122,15 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
expect(result).to include('Channel: Test Inbox')
# Check that messages appear in reverse order (newest first)
newer = formatted_line_for(message2, hook)
older = formatted_line_for(message1, hook)
message_positions = {
'[2024-01-01 10:00] John Doe: Hello' => result.index('[2024-01-01 10:00] John Doe: Hello'),
'[2024-01-01 10:01] Jane Smith: Hi there' => result.index('[2024-01-01 10:01] Jane Smith: Hi there')
newer => result.index(newer),
older => result.index(older)
}
# Latest message (10:01) should come before older message (10:00)
expect(message_positions['[2024-01-01 10:01] Jane Smith: Hi there']).to be < message_positions['[2024-01-01 10:00] John Doe: Hello']
expect(message_positions[newer]).to be < message_positions[older]
end
it 'formats message times according to hook timezone setting' do
@@ -210,13 +219,15 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
sender: user,
content: "#{long_message_content} #{i}",
message_type: :outgoing,
created_at: Time.zone.parse("2024-01-01 #{10 + i}:00:00"))
created_at: Time.zone.parse('2024-01-01 10:00:00') + i.hours)
end
result = described_class.map_transcript_activity(hook, conversation)
# Verify latest message is included (message 14)
expect(result).to include("[2024-01-02 00:00] John Doe: #{long_message_content} 14")
tz = Time.find_zone(hook.settings['timezone']) || Time.zone
latest_label = "[#{messages.last.created_at.in_time_zone(tz).strftime('%Y-%m-%d %H:%M')}] John Doe: #{long_message_content} 14"
expect(result).to include(latest_label)
# Calculate the expected character count of the formatted messages
messages.map do |msg|
+1 -1
View File
@@ -6,7 +6,7 @@ describe Widget::TokenService do
describe 'inheritance' do
it 'inherits from BaseTokenService' do
expect(described_class.superclass).to eq(BaseTokenService)
expect(described_class.superclass.name).to eq('BaseTokenService')
end
end
+6
View File
@@ -99,6 +99,12 @@ export default defineConfig({
},
globals: true,
outputFile: 'coverage/sonar-report.xml',
pool: 'threads',
poolOptions: {
threads: {
singleThread: false,
},
},
server: {
deps: {
inline: ['tinykeys', '@material/mwc-icon'],