Compare commits

..
1 Commits
Author SHA1 Message Date
2a1dd481e9 test(playwright): add agent onboarding and inbox creation UI tests (#14707)
Frontend Lint & Test / test (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Run Chatwoot CE spec / backend-tests (0, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (1, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (10, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (11, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (12, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (13, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (14, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (15, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (2, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (3, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (4, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (5, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (6, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (7, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (8, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (9, 16) (push) Waiting to run
Build Chatwoot / build (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Failing after 15m13s
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Failing after 14m33s
Run Chatwoot CE spec / lint-backend (push) In progress
Run Chatwoot CE spec / lint-frontend (push) In progress
Run Chatwoot CE spec / frontend-tests (push) In progress
Adds E2E UI tests for two core Phase 2 flows, building on the Playwright
setup from #13578.

**Agent onboarding** — validates the Agents settings page, Add Agent
modal elements, form validation (name + email required, submit disabled
until valid), and cancel behaviour.

**Inbox creation** — walks through the full API channel inbox creation
journey: channel selection → form fill → agent assignment → finish
screen.

## What changed

New UI component objects (`tests/playwright/components/ui/`):
- `agent-page.component.ts`
- `add-agent-modal.component.ts`
- `add-agents-form.component.ts`
- `settings-inbox-page.component.ts`
- `channel-selector.component.ts`
- `api-channel-form.component.ts`
- `finish-setup.component.ts`

New test specs (`tests/playwright/tests/e2e/ui/`):
- `agent-onboarding-flow-ui-validation.spec.ts`
- `inbox-creation-flow.spec.ts`

Updated `components/ui/index.ts` barrel export to include all new
components.

## How to test

```bash
cd tests/playwright
npx playwright test tests/e2e/ui/
```

All 5 tests pass locally (3 login + 2 new flows).

Closes part of the Phase 2 scope from the [Playwright E2E discussion
#13500](https://github.com/orgs/chatwoot/discussions/13500).

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-07-23 23:24:03 +05:30
27 changed files with 501 additions and 329 deletions
@@ -79,21 +79,13 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived,
{ config: config_param_keys }
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] }
)
end
def config_param_keys
keys = [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }]
# Analytics injects tracking scripts into every public page, so keep it admin-only even though
# Enterprise lets knowledge_base_manage roles edit other portal settings.
keys << { analytics: %i[ga4 gtm clarity hotjar meta_pixel] } if Current.account_user&.administrator?
keys
end
def locale_translation_keys
params.dig(:portal, :config, :locale_translations)&.keys || []
end
@@ -1,148 +0,0 @@
<script setup>
import { computed, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Select from 'dashboard/components-next/select/Select.vue';
const props = defineProps({
activePortal: { type: Object, required: true },
isFetching: { type: Boolean, default: false },
});
const emit = defineEmits(['updatePortalConfiguration']);
const { t } = useI18n();
// `pattern` mirrors Portal::ANALYTICS_PROVIDERS to flag bad ids before saving.
const PROVIDERS = computed(() => [
{
key: 'ga4',
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.GA4.LABEL'),
placeholder: 'G-XXXXXXXXXX',
pattern: /^G-[A-Z0-9]+$/,
},
{
key: 'gtm',
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.GTM.LABEL'),
placeholder: 'GTM-XXXXXXX',
pattern: /^GTM-[A-Z0-9]+$/,
},
{
key: 'clarity',
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.CLARITY.LABEL'),
placeholder: 'abcd1234ef',
pattern: /^[a-z0-9]+$/,
},
{
key: 'hotjar',
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.HOTJAR.LABEL'),
placeholder: '1234567',
pattern: /^\d+$/,
},
{
key: 'meta_pixel',
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.META_PIXEL.LABEL'),
placeholder: '1234567890',
pattern: /^\d+$/,
},
]);
const portalConfig = computed(() => props.activePortal?.config || {});
const providerFor = key =>
PROVIDERS.value.find(provider => provider.key === key);
const state = reactive({ provider: PROVIDERS.value[0].key, id: '' });
let saved = { provider: '', id: '' };
const resetFromPortal = () => {
const analytics = portalConfig.value.analytics || {};
const [key, value] = Object.entries(analytics)[0] || [];
saved = { provider: key || '', id: value || '' };
state.provider = saved.provider || PROVIDERS.value[0].key;
state.id = saved.id;
};
watch(() => props.activePortal, resetFromPortal, {
immediate: true,
deep: true,
});
// Clear the id when switching to a provider other than the saved one.
watch(
() => state.provider,
provider => {
state.id = provider === saved.provider ? saved.id : '';
}
);
const providerOptions = computed(() =>
PROVIDERS.value.map(provider => ({
value: provider.key,
label: provider.name,
}))
);
const currentProvider = computed(() => providerFor(state.provider));
const error = computed(() => {
const value = state.id.trim();
if (!value || currentProvider.value.pattern.test(value)) return '';
return t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.INVALID_ID');
});
const hasChanges = computed(() => {
const id = state.id.trim();
if (!id) return saved.id !== '';
return id !== saved.id || state.provider !== saved.provider;
});
const handleSave = () => {
const id = state.id.trim();
const analytics = id ? { [state.provider]: id } : {};
emit('updatePortalConfiguration', {
id: props.activePortal.id,
slug: props.activePortal.slug,
config: { analytics },
});
};
</script>
<template>
<div class="flex flex-col w-full gap-6">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.HEADER') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.DESCRIPTION') }}
</span>
</div>
<section class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.PROVIDER') }}
</label>
<Select v-model="state.provider" :options="providerOptions" />
</div>
<Input
v-model="state.id"
:label="currentProvider.name"
:placeholder="currentProvider.placeholder"
:message="error"
:message-type="error ? 'error' : 'info'"
/>
</section>
<div class="flex justify-end">
<Button
:label="t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.SAVE')"
:disabled="!hasChanges || Boolean(error) || isFetching"
@click="handleSave"
/>
</div>
</div>
</template>
@@ -3,13 +3,11 @@ import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useAdmin } from 'dashboard/composables/useAdmin';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
import PortalLayoutContentSettings from './PortalLayoutContentSettings.vue';
import PortalAnalyticsSettings from './PortalAnalyticsSettings.vue';
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -35,7 +33,6 @@ const emit = defineEmits([
const { t } = useI18n();
const route = useRoute();
const { isAdmin } = useAdmin();
const confirmDeletePortalDialogRef = ref(null);
@@ -111,14 +108,6 @@ const handleDeletePortal = () => {
:is-fetching="isFetching"
@update-portal-configuration="handleUpdatePortalConfiguration"
/>
<template v-if="isAdmin">
<div class="w-full h-px bg-n-weak" />
<PortalAnalyticsSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal-configuration="handleUpdatePortalConfiguration"
/>
</template>
<div class="w-full h-px bg-n-weak" />
<div class="flex items-end justify-between w-full gap-4">
<div class="flex flex-col gap-2">
@@ -972,28 +972,6 @@
},
"SAVE": "Save changes"
},
"ANALYTICS": {
"HEADER": "Analytics",
"DESCRIPTION": "Choose an analytics provider and enter its ID. We add the tracking code to every public help center page automatically. Switching providers replaces the previous one.",
"PROVIDER": "Provider",
"INVALID_ID": "This doesn't look like a valid ID for this provider.",
"GA4": {
"LABEL": "Google Analytics (GA4)"
},
"GTM": {
"LABEL": "Google Tag Manager"
},
"CLARITY": {
"LABEL": "Microsoft Clarity"
},
"HOTJAR": {
"LABEL": "Hotjar"
},
"META_PIXEL": {
"LABEL": "Meta Pixel"
},
"SAVE": "Save changes"
},
"API": {
"CREATE_PORTAL": {
"SUCCESS_MESSAGE": "Portal created successfully",
@@ -103,6 +103,7 @@ export default {
<label :class="{ error: v$.selectedAgentIds.$error }">
{{ $t('INBOX_MGMT.ADD.AGENTS.TITLE') }}
<div
data-testid="agent-selector"
class="rounded-xl outline outline-1 -outline-offset-1 outline-n-weak hover:outline-n-strong px-2 py-2"
>
<TagInput
@@ -33,7 +33,6 @@ const state = {
allFetched: false,
isFetching: false,
isSwitching: false,
isFetchingSSLStatus: false,
},
};
@@ -43,11 +43,6 @@ module PortalConfigSchema
'popular_content' => {
'type' => %w[object null],
'additionalProperties' => POPULAR_CONTENT_SCHEMA
},
# Analytics ids keyed by provider, e.g. { "ga4" => "G-XXXX" }.
'analytics' => {
'type' => %w[object null],
'additionalProperties' => { 'type' => %w[string null] }
}
},
'required' => [],
+1 -30
View File
@@ -46,7 +46,6 @@ class Portal < ApplicationRecord
validates :color, format: { with: /\A#(?:\h{3}|\h{6})\z/ }, allow_blank: true
before_validation :normalize_config
validate :validate_config
validate :validate_analytics
validates_with JsonSchemaValidator,
schema: PortalConfigSchema::CONFIG_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.config }
@@ -55,17 +54,7 @@ class Portal < ApplicationRecord
# TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations
popular_content analytics].freeze
# Analytics providers and their accepted id formats. Add a provider here and its
# snippet in layouts/_portal_analytics.html.erb.
ANALYTICS_PROVIDERS = {
'ga4' => /\AG-[A-Z0-9]+\z/,
'gtm' => /\AGTM-[A-Z0-9]+\z/,
'clarity' => /\A[a-z0-9]+\z/,
'hotjar' => /\A\d+\z/,
'meta_pixel' => /\A\d+\z/
}.freeze
popular_content].freeze
# Max number of recommended categories/articles shown per locale.
POPULAR_CATEGORY_LIMIT = 3
@@ -143,11 +132,6 @@ class Portal < ApplicationRecord
config_value('social_profiles') || {}
end
# Valid analytics ids keyed by provider; drops unknown providers and blank ids.
def analytics
(config_value('analytics') || {}).select { |provider, id| ANALYTICS_PROVIDERS.key?(provider) && id.present? }
end
private
def normalize_config
@@ -163,19 +147,6 @@ class Portal < ApplicationRecord
errors.add(:config, 'default locale cannot be drafted.') if draft_locale?(default_locale)
end
def validate_analytics
(config_value('analytics') || {}).each do |provider, id|
next if id.blank?
format = ANALYTICS_PROVIDERS[provider]
if format.nil?
errors.add(:config, "analytics provider #{provider} is not supported")
elsif !id.match?(format)
errors.add(:config, "analytics id for #{provider} is invalid")
end
end
end
def normalize_locale_codes(locale_codes)
Array(locale_codes).filter_map(&:presence).uniq
end
@@ -20,7 +20,6 @@ json.config do
json.social_profiles portal.social_profiles
json.locale_translations portal.config['locale_translations'] || {}
json.popular_content portal.config['popular_content'] || {}
json.analytics portal.config['analytics'] || {}
end
if portal.channel_web_widget
@@ -1,65 +0,0 @@
<%# Analytics snippets for the portal's configured ids (validated on save). %>
<%# The raw output tag emits to_json so JS strings are not HTML-escaped. %>
<% analytics = @portal.analytics %>
<% nonce = request.content_security_policy_nonce %>
<% if analytics['ga4'].present? %>
<!-- Google Analytics (GA4) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=<%= analytics['ga4'] %>" nonce="<%= nonce %>"></script>
<script nonce="<%= nonce %>">
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', <%== analytics['ga4'].to_json %>);
</script>
<% end %>
<% if analytics['gtm'].present? %>
<!-- Google Tag Manager -->
<script nonce="<%= nonce %>">(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer',<%== analytics['gtm'].to_json %>);</script>
<% end %>
<% if analytics['clarity'].present? %>
<!-- Microsoft Clarity -->
<script nonce="<%= nonce %>">
(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", <%== analytics['clarity'].to_json %>);
</script>
<% end %>
<% if analytics['hotjar'].present? %>
<!-- Hotjar -->
<script nonce="<%= nonce %>">
(function(h,o,t,j,a,r){
h.hj=h.hj||function(){(h.hj.q=h.hj.q||[]).push(arguments)};
h._hjSettings={hjid:<%== analytics['hotjar'].to_json %>,hjsv:6};
a=o.getElementsByTagName('head')[0];
r=o.createElement('script');r.async=1;
r.src=t+h._hjSettings.hjid+j+h._hjSettings.hjsv;
a.appendChild(r);
})(window,document,'https://static.hotjar.com/c/hotjar-','.js?sv=');
</script>
<% end %>
<% if analytics['meta_pixel'].present? %>
<!-- Meta Pixel -->
<script nonce="<%= nonce %>">
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', <%== analytics['meta_pixel'].to_json %>);
fbq('track', 'PageView');
</script>
<% end %>
@@ -1,7 +0,0 @@
<%# GTM noscript fallback; must sit right after <body> (invalid in <head>). %>
<% if @portal.analytics['gtm'].present? %>
<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=<%= @portal.analytics['gtm'] %>"
title="Google Tag Manager" height="0" width="0"
style="display:none;visibility:hidden"></iframe></noscript>
<% end %>
-2
View File
@@ -23,8 +23,6 @@
<link rel="icon" href="<%= url_for(@portal.logo) %>">
<% end %>
<%= render 'layouts/portal_analytics' %>
<% unless @theme_from_params.blank? %>
<%# this adds the theme from params, ensuring that there a localstorage value set %>
<%# this will further trigger the next script to ensure color mode is toggled without a FOUC %>
@@ -4,7 +4,6 @@
<%= render 'layouts/portal_head' %>
</head>
<body class="font-inter bg-white dark:bg-slate-900">
<%= render 'layouts/portal_analytics_body' %>
<div id="portal" class="antialiased">
<main class="flex flex-col min-h-screen main-content" role="main">
<%= render 'public/api/v1/portals/documentation_layout/topbar',
-1
View File
@@ -4,7 +4,6 @@
<%= render 'layouts/portal_head' %>
</head>
<body class="font-default bg-white dark:bg-slate-900">
<%= render 'layouts/portal_analytics_body' %>
<div id="portal" class="antialiased">
<main class="flex flex-col min-h-screen main-content" role="main">
<%= yield %>
-1
View File
@@ -4,7 +4,6 @@
<%= render 'layouts/portal_head' %>
</head>
<body class="font-default bg-white dark:bg-slate-900">
<%= render 'layouts/portal_analytics_body' %>
<div id="portal" class="antialiased">
<main class="flex flex-col min-h-screen main-content" role="main">
<%= render 'public/api/v1/portals/header', portal: @portal %>
@@ -175,21 +175,11 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
'layout' => 'classic',
'social_profiles' => {},
'locale_translations' => {},
'popular_content' => {},
'analytics' => {}
'popular_content' => {}
}
)
end
it 'allows administrators to set analytics config' do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
params: { portal: { config: { analytics: { ga4: 'G-ADMIN12345' } } } },
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(portal.reload.config['analytics']).to eq('ga4' => 'G-ADMIN12345')
end
it 'preserves drafted locales when draft_locales is omitted' do
portal.update!(config: { allowed_locales: %w[en es fr], draft_locales: ['es'], default_locale: 'en' })
@@ -85,17 +85,6 @@ RSpec.describe 'Enterprise Portal API', type: :request do
json_response = response.parsed_body
expect(json_response['name']).to eq('updated_portal')
end
it 'ignores analytics config for knowledge_base_manage users' do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
params: { portal: { name: 'updated_portal', config: { analytics: { ga4: 'G-KBMANAGER1' } } } },
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(portal.reload.name).to eq('updated_portal')
expect(portal.config['analytics']).to be_blank
end
end
end
@@ -0,0 +1,61 @@
import { Page } from '@playwright/test';
export class AddAgentModal {
private page: Page;
constructor(page: Page) {
this.page = page;
}
getModalTitle() {
return this.page.locator('[data-test-id="modal-header-title"]');
}
getAgentNameInput() {
return this.page.getByRole('textbox', { name: 'Agent Name' });
}
getEmailInput() {
return this.page.getByRole('textbox', { name: 'Email Address' });
}
getRoleCombobox() {
return this.page.getByRole('combobox', { name: 'Role' });
}
getSubmitButton() {
return this.page.locator('form').getByRole('button', { name: 'Add Agent' });
}
getCancelButton() {
return this.page.getByRole('button', { name: 'Cancel' });
}
getSuccessMessage() {
return this.page.getByText('Agent added successfully');
}
async fillAgentName(name: string) {
await this.getAgentNameInput().fill(name);
await this.page.waitForTimeout(1000);
}
async fillEmail(email: string) {
await this.getEmailInput().fill(email);
await this.page.waitForTimeout(1000);
}
async submitForm() {
await this.getSubmitButton().click();
}
async cancelForm() {
await this.getCancelButton().click();
}
async createAgent(name: string, email: string) {
await this.fillAgentName(name);
await this.fillEmail(email);
await this.submitForm();
}
}
@@ -0,0 +1,73 @@
import { Page } from '@playwright/test';
export class AddAgentsForm {
constructor(private page: Page) {}
getPageHeading() {
return this.page
.locator('form')
.getByRole('heading', { name: 'Agents', level: 2, exact: true });
}
getAgentDropdown() {
return this.page.getByPlaceholder('Pick agents for the inbox');
}
getAgentSelector() {
return this.page.getByTestId('agent-selector');
}
getAgentOption(agentName: string) {
return this.getAgentSelector().getByRole('button', {
name: agentName,
exact: true,
});
}
getDropdownButtons() {
return this.getAgentSelector().getByRole('button');
}
getSubmitButton() {
return this.page.getByRole('button', { name: 'Add agents' });
}
async openAgentDropdown() {
await this.getAgentDropdown().click();
}
async selectAgent(agentName: string) {
await this.openAgentDropdown();
await this.getAgentOption(agentName).waitFor({ state: 'visible' });
await this.getAgentOption(agentName).click();
}
async selectAgentByIndex(index: number = 0) {
await this.openAgentDropdown();
const buttons = this.getDropdownButtons();
await buttons.first().waitFor({ state: 'visible' });
await buttons.nth(index).click();
}
async closeDropdown() {
await this.page.keyboard.press('Escape');
}
async submitForm() {
await this.getSubmitButton().click();
}
async addAgents(agentNames: string[]) {
for (const agentName of agentNames) {
await this.selectAgent(agentName);
}
await this.closeDropdown();
await this.submitForm();
}
async addFirstAgent() {
await this.selectAgentByIndex(0);
await this.closeDropdown();
await this.submitForm();
}
}
@@ -0,0 +1,35 @@
import { Page } from '@playwright/test';
export class AgentPage {
private page: Page;
constructor(page: Page) {
this.page = page;
}
async navigate(accountId: number = 1) {
await this.page.goto(`/app/accounts/${accountId}/settings/agents/list`);
}
getPageHeading() {
return this.page.getByRole('heading', { name: 'Agents', level: 1 });
}
getDescriptionText() {
return this.page.getByText(
'An agent is a member of your customer support team who can view and respond to user messages.'
);
}
getLearnLink() {
return this.page.getByRole('link', { name: 'Learn about user roles' });
}
getAddAgentButton() {
return this.page.getByRole('button', { name: 'Add Agent' });
}
async openAddAgentModal() {
await this.getAddAgentButton().click();
}
}
@@ -0,0 +1,41 @@
import { Page } from '@playwright/test';
export class ApiChannelForm {
constructor(private page: Page) {}
getChannelNameInput() {
return this.page.getByRole('textbox', { name: 'Channel Name' });
}
getWebhookUrlInput() {
return this.page.getByRole('textbox', { name: 'Webhook URL' });
}
getSubmitButton() {
return this.page.getByRole('button', { name: 'Create API Channel' });
}
async fillChannelName(name: string) {
await this.getChannelNameInput().fill(name);
}
async fillWebhookUrl(url: string) {
await this.getWebhookUrlInput().fill(url);
}
async submitForm() {
await this.getSubmitButton().click();
}
async createApiChannel(channelName: string, webhookUrl?: string) {
await this.fillChannelName(channelName);
if (webhookUrl) {
await this.fillWebhookUrl(webhookUrl);
}
await this.submitForm();
}
getValidationError() {
return this.page.locator('.message, .error-message').first();
}
}
@@ -0,0 +1,25 @@
import { Page } from '@playwright/test';
export class ChannelSelector {
constructor(private page: Page) {}
getPageHeading() {
return this.page.getByRole('heading', { name: /choose channel/i });
}
getApiChannelCard() {
return this.page.getByRole('button', { name: /API.*Make a custom channel/i });
}
getWebsiteChannelCard() {
return this.page.getByRole('button', { name: /Website.*Create a live-chat widget/i });
}
async selectApiChannel() {
await this.getApiChannelCard().click();
}
async selectWebsiteChannel() {
await this.getWebsiteChannelCard().click();
}
}
@@ -0,0 +1,32 @@
import { Page } from '@playwright/test';
export class FinishSetup {
constructor(private page: Page) {}
getPageHeading() {
return this.page.getByRole('heading', {
name: 'Your Inbox is ready!',
exact: true,
});
}
getGoToInboxButton() {
return this.page.getByRole('button', { name: /go to inbox|view inbox/i });
}
getMoreSettingsButton() {
return this.page.getByRole('button', { name: /more settings|settings/i });
}
getWebhookUrl() {
return this.page.locator('code, pre').filter({ hasText: /http/i }).first();
}
async goToInbox() {
await this.getGoToInboxButton().click();
}
async goToSettings() {
await this.getMoreSettingsButton().click();
}
}
+7
View File
@@ -1 +1,8 @@
export { Login } from './login.component';
export { AgentPage } from './agent-page.component';
export { AddAgentModal } from './add-agent-modal.component';
export { AddAgentsForm } from './add-agents-form.component';
export { SettingsInboxPage } from './settings-inbox-page.component';
export { ChannelSelector } from './channel-selector.component';
export { ApiChannelForm } from './api-channel-form.component';
export { FinishSetup } from './finish-setup.component';
@@ -0,0 +1,57 @@
import { Page } from '@playwright/test';
type DashboardApi = {
delete: (url: string) => Promise<unknown>;
get: (url: string) => Promise<{
data: {
payload: Array<{ id: number }>;
};
}>;
};
export class SettingsInboxPage {
constructor(private page: Page) {}
async navigate(accountId: number = 1) {
await this.page.goto(`/app/accounts/${accountId}/settings/inboxes/list`);
}
getAddInboxButton() {
return this.page.getByRole('link', { name: 'Add Inbox' });
}
async clickAddInboxButton() {
await this.getAddInboxButton().click();
}
getPageHeading() {
return this.page.getByRole('heading', { name: /inboxes/i });
}
async deleteInbox(accountId: number, inboxId: number) {
await this.page.evaluate(
async ({ accountId: currentAccountId, inboxId: currentInboxId }) => {
const api = (window as typeof window & { axios: DashboardApi }).axios;
await api.delete(
`/api/v1/accounts/${currentAccountId}/inboxes/${currentInboxId}`
);
},
{ accountId, inboxId }
);
}
async isInboxPresent(accountId: number, inboxId: number) {
return this.page.evaluate(
async ({ accountId: currentAccountId, inboxId: currentInboxId }) => {
const api = (window as typeof window & { axios: DashboardApi }).axios;
const response = await api.get(
`/api/v1/accounts/${currentAccountId}/inboxes`
);
return response.data.payload.some(
inbox => inbox.id === currentInboxId
);
},
{ accountId, inboxId }
);
}
}
@@ -0,0 +1,60 @@
import { test, expect } from '@playwright/test';
import { AddAgentModal, AgentPage, Login } from '@components/ui';
const TEST_EMAIL = process.env.TEST_USER_EMAIL || 'admin@chatwoot.com';
const TEST_PASSWORD = process.env.TEST_USER_PASSWORD || 'Password123@#';
test.describe('Agent Onboarding - UI', () => {
let loginComponent: Login;
let agentPage: AgentPage;
let addAgentModal: AddAgentModal;
test.beforeEach(async ({ page }) => {
loginComponent = new Login(page);
agentPage = new AgentPage(page);
addAgentModal = new AddAgentModal(page);
await loginComponent.navigate();
await loginComponent.login(TEST_EMAIL, TEST_PASSWORD);
await expect(page).toHaveURL(/\/app\/accounts\/\d+\/dashboard/);
const accountId = Number(page.url().match(/\/app\/accounts\/(\d+)\//)![1]);
await agentPage.navigate(accountId);
});
test('should validate all UI elements on agents page', async () => {
await expect(agentPage.getPageHeading()).toBeVisible();
await expect(agentPage.getDescriptionText()).toBeVisible();
const learnLink = agentPage.getLearnLink();
await expect(learnLink).toBeVisible();
await expect(learnLink).toHaveAttribute('href', 'https://chwt.app/hc/agents');
await expect(agentPage.getAddAgentButton()).toBeVisible();
await agentPage.openAddAgentModal();
await expect(addAgentModal.getModalTitle()).toBeVisible();
await expect(addAgentModal.getModalTitle()).toHaveText('Add agent to your team');
await expect(addAgentModal.getAgentNameInput()).toBeVisible();
await expect(addAgentModal.getEmailInput()).toBeVisible();
await expect(addAgentModal.getRoleCombobox()).toBeVisible();
await expect(addAgentModal.getSubmitButton()).toBeVisible();
await expect(addAgentModal.getCancelButton()).toBeVisible();
await expect(addAgentModal.getSubmitButton()).toBeDisabled();
await addAgentModal.getAgentNameInput().fill('Test');
await expect(addAgentModal.getSubmitButton()).toBeDisabled();
await addAgentModal.getAgentNameInput().clear();
await addAgentModal.getEmailInput().fill('test@example.com');
await expect(addAgentModal.getSubmitButton()).toBeDisabled();
await addAgentModal.getAgentNameInput().fill('Test');
await expect(addAgentModal.getSubmitButton()).toBeEnabled();
await addAgentModal.cancelForm();
await expect(addAgentModal.getModalTitle()).toBeHidden();
});
});
@@ -0,0 +1,103 @@
import { test, expect } from '@playwright/test';
import {
AddAgentsForm,
ApiChannelForm,
ChannelSelector,
FinishSetup,
Login,
SettingsInboxPage,
} from '@components/ui';
const TEST_EMAIL = process.env.TEST_USER_EMAIL || 'admin@chatwoot.com';
const TEST_PASSWORD = process.env.TEST_USER_PASSWORD || 'Password123@#';
test.describe('Inbox Creation - UI Flow', () => {
const testInbox = {
name: `Test Inbox ${Date.now()}`,
webhookUrl: 'https://example.com/webhook',
};
let accountId: number | undefined;
let inboxId: number | undefined;
test.beforeEach(() => {
accountId = undefined;
inboxId = undefined;
});
test.afterEach(async ({ page }) => {
const currentAccountId = accountId;
const currentInboxId = inboxId;
if (!currentAccountId || !currentInboxId) {
return;
}
const settingsInboxPage = new SettingsInboxPage(page);
await settingsInboxPage.deleteInbox(currentAccountId, currentInboxId);
await expect
.poll(
() =>
settingsInboxPage.isInboxPresent(currentAccountId, currentInboxId),
{
message: `Inbox ${currentInboxId} was not deleted`,
timeout: 30_000,
}
)
.toBe(false);
});
test('should complete full inbox creation flow with UI validation', async ({
page,
}) => {
const loginComponent = new Login(page);
await loginComponent.navigate();
await loginComponent.login(TEST_EMAIL, TEST_PASSWORD);
await page.waitForURL(/\/app\/accounts\/\d+\/dashboard/);
accountId = Number(page.url().match(/\/app\/accounts\/(\d+)\//)![1]);
const settingsInboxPage = new SettingsInboxPage(page);
await settingsInboxPage.navigate(accountId);
await expect(settingsInboxPage.getPageHeading()).toBeVisible();
await expect(settingsInboxPage.getAddInboxButton()).toBeVisible();
await settingsInboxPage.clickAddInboxButton();
await page.waitForURL(/\/settings\/inboxes\/new/);
const channelSelector = new ChannelSelector(page);
await expect(channelSelector.getPageHeading()).toBeVisible();
await channelSelector.selectApiChannel();
page.on('response', async response => {
if (
response.url().includes('/api/v1/accounts/') &&
response.url().includes('/inboxes') &&
response.request().method() === 'POST' &&
response.status() === 200
) {
try {
const responseData = await response.json();
if (responseData.id) {
inboxId = responseData.id;
}
} catch {
// ignore non-JSON responses
}
}
});
const apiChannelForm = new ApiChannelForm(page);
await apiChannelForm.fillChannelName(testInbox.name);
await apiChannelForm.fillWebhookUrl(testInbox.webhookUrl);
await apiChannelForm.submitForm();
await expect.poll(() => inboxId).toBeTruthy();
const addAgentsForm = new AddAgentsForm(page);
await expect(addAgentsForm.getPageHeading()).toBeVisible();
await addAgentsForm.addFirstAgent();
await page.waitForURL(/\/settings\/inboxes\/.*\/finish/);
const finishSetup = new FinishSetup(page);
await expect(finishSetup.getPageHeading()).toBeVisible();
});
});