diff --git a/packages/ui/src/components/NavItem.svelte b/packages/ui/src/components/NavItem.svelte index 6ad282e806..0040609981 100644 --- a/packages/ui/src/components/NavItem.svelte +++ b/packages/ui/src/components/NavItem.svelte @@ -61,7 +61,6 @@ let levelReset: boolean = false let hovered: boolean = false - $: showArrow = selected && (type === 'type-link' || type === 'type-object') $: if (!showMenu && levelReset && !hovered) levelReset = false $: isOpen = !getTreeCollapsed(_id, collapsedPrefix) $: setTreeCollapsed(_id, !isOpen, collapsedPrefix) @@ -147,14 +146,11 @@ {/if} {#if count !== null} - + {count} {/if} - {#if showArrow} -
- {/if} {#if (isFold && (isOpen || (!isOpen && visible)) && !empty) || forciblyСollapsed}
@@ -202,6 +198,7 @@ } } .hulyNavItem-icon { + margin-right: var(--spacing-1); width: var(--global-min-Size); height: var(--global-min-Size); color: var(--global-primary-TextColor); @@ -212,14 +209,6 @@ height: 0.625rem; border-radius: var(--min-BorderRadius); } - &.right { - visibility: hidden; - margin-left: var(--spacing-0_5); - color: var(--global-accent-IconColor); - } - &:not(.right) { - margin-right: var(--spacing-1); - } &.withBackground { width: var(--global-extra-small-Size); height: var(--global-extra-small-Size); @@ -245,12 +234,9 @@ gap: var(--spacing-0_25); } .hulyNavItem-count { - margin-left: var(--spacing-1); + margin: 0 var(--spacing-1); color: var(--global-tertiary-TextColor); } - &:not(.selected) .hulyNavItem-count { - margin-right: var(--spacing-1); - } &:not(.selected):hover, &:not(.selected).showMenu { background-color: var(--global-ui-hover-highlight-BackgroundColor); @@ -259,27 +245,15 @@ cursor: default; background-color: var(--global-ui-highlight-BackgroundColor); - // &:not(.type-anchor-link) .hulyNavItem-label:not(.description) { - // font-weight: 700; - // } - .hulyNavItem-actions { - order: 1; - margin-left: var(--spacing-0_5); - } .hulyNavItem-count { color: var(--global-secondary-TextColor); } } - // &.bold:not(.type-anchor-link) .hulyNavItem-label:not(.description) { - // font-weight: 700; - // } &.type-link { padding: 0 var(--spacing-0_5) 0 var(--spacing-1_25); &.selected { - padding: 0 var(--spacing-0_75) 0 var(--spacing-1_25); - &.indent { padding-left: var(--spacing-4); } @@ -289,9 +263,6 @@ .hulyNavItem-label:not(.description) { color: var(--global-accent-TextColor); } - .hulyNavItem-icon.right { - visibility: visible; - } } } &.type-tag { @@ -303,17 +274,14 @@ } } &.type-object { - padding: 0 var(--spacing-0_5) 0 var(--spacing-0_5); + padding: 0 var(--spacing-0_5); .hulyNavItem-icon { + margin-right: var(--spacing-0_75); width: var(--global-extra-small-Size); height: var(--global-extra-small-Size); - - &:not(.right) { - margin-right: var(--spacing-0_75); - background-color: var(--global-ui-BackgroundColor); - border-radius: var(--extra-small-BorderRadius); - } + background-color: var(--global-ui-BackgroundColor); + border-radius: var(--extra-small-BorderRadius); } &.selected { .hulyNavItem-label:not(.description) { @@ -321,10 +289,6 @@ } .hulyNavItem-icon { color: var(--global-accent-TextColor); - - &.right { - visibility: visible; - } } } } @@ -353,14 +317,9 @@ background-color: var(--button-tertiary-hover-BackgroundColor); } - &:not(.noActions):hover, - &:not(.noActions).showMenu { - .hulyNavItem-actions { - display: flex; - } - .hulyNavItem-icon.right { - display: none; - } + &:not(.noActions):hover .hulyNavItem-actions, + &:not(.noActions).showMenu .hulyNavItem-actions { + display: flex; } &.disabled { cursor: not-allowed; diff --git a/plugins/workbench-resources/src/components/ServerManagerUsers.svelte b/plugins/workbench-resources/src/components/ServerManagerUsers.svelte index 47aad15c92..4f4f5aaeb3 100644 --- a/plugins/workbench-resources/src/components/ServerManagerUsers.svelte +++ b/plugins/workbench-resources/src/components/ServerManagerUsers.svelte @@ -80,8 +80,11 @@
{#each Object.entries(activeSessions) as act} {@const wsInstance = $workspacesStore.find((it) => it.workspaceId === act[0])} - {@const totalFind = act[1].sessions.reduce((it, itm) => itm.current.find + it, 0)} - {@const totalTx = act[1].sessions.reduce((it, itm) => itm.current.tx + it, 0)} + {@const totalFind = act[1].sessions.reduce((it, itm) => itm.total.find + it, 0)} + {@const totalTx = act[1].sessions.reduce((it, itm) => itm.total.tx + it, 0)} + + {@const currentFind = act[1].sessions.reduce((it, itm) => itm.current.find + it, 0)} + {@const currentTx = act[1].sessions.reduce((it, itm) => itm.current.tx + it, 0)} {@const employeeGroups = Array.from(new Set(act[1].sessions.map((it) => it.userId))).filter( (it) => systemAccountEmail !== it || !realUsers )} @@ -94,7 +97,8 @@
- Workspace: {wsInstance?.workspaceName ?? act[0]}: {employeeGroups.length} current 5 mins => {totalFind}/{totalTx} + Workspace: {wsInstance?.workspaceName ?? act[0]}: {employeeGroups.length} current 5 mins => {currentFind}/{currentTx}, + total => {totalFind}/{totalTx} {#if act[1].upgrading} (Upgrading) {/if} @@ -138,7 +142,7 @@ {/if} : {connections.length}
-
{find}/{txes}
+
{find} rx/{txes} tx
@@ -147,13 +151,13 @@ #{i} {user.userId}
- Total: {user.total.find}/{user.total.tx} + Total: {user.total.find} rx/{user.total.tx} tx
- Previous 5 mins: {user.mins5.find}/{user.mins5.tx} + Previous 5 mins: {user.mins5.find} rx/{user.mins5.tx} tx
- Current 5 mins: {user.current.find}/{user.current.tx} + Current 5 mins: {user.current.find} tx/{user.current.tx} tx
diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index 77d502828e..b27478d2fc 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -1812,7 +1812,7 @@ export async function setRole ( }) } } finally { - if (client === undefined) { + if (client == null) { await connection.close() } } @@ -2049,7 +2049,7 @@ async function createPersonAccount ( } } } finally { - if (client === undefined) { + if (client == null) { await connection.close() } } diff --git a/server/core/src/dbAdapterManager.ts b/server/core/src/dbAdapterManager.ts index 985462a3b2..a27bbd1fab 100644 --- a/server/core/src/dbAdapterManager.ts +++ b/server/core/src/dbAdapterManager.ts @@ -14,11 +14,12 @@ // limitations under the License. // -import { type Domain, type MeasureContext } from '@hcengineering/core' +import { Analytics } from '@hcengineering/analytics' +import { DOMAIN_TX, type Domain, type MeasureContext } from '@hcengineering/core' import { type DbAdapter, type DomainHelper } from './adapter' +import type { DbConfiguration } from './configuration' import { DummyDbAdapter } from './mem' import type { DBAdapterManager, PipelineContext } from './types' -import { Analytics } from '@hcengineering/analytics' interface DomainInfo { exists: boolean @@ -35,7 +36,7 @@ export class DbAdapterManagerImpl implements DBAdapterManager { constructor ( private readonly metrics: MeasureContext, - private readonly _domains: Record, + readonly conf: DbConfiguration, private readonly context: PipelineContext, private readonly defaultAdapter: DbAdapter, private readonly adapters: Map @@ -86,8 +87,36 @@ export class DbAdapterManagerImpl implements DBAdapterManager { } } + async initAdapters (ctx: MeasureContext): Promise { + await ctx.with('init-adapters', {}, async (ctx) => { + for (const [key, adapter] of this.adapters) { + // already initialized + if (key !== this.conf.domains[DOMAIN_TX] && adapter.init !== undefined) { + let excludeDomains: string[] | undefined + let domains: string[] | undefined + if (this.conf.defaultAdapter === key) { + excludeDomains = [] + for (const domain in this.conf.domains) { + if (this.conf.domains[domain] !== key) { + excludeDomains.push(domain) + } + } + } else { + domains = [] + for (const domain in this.conf.domains) { + if (this.conf.domains[domain] === key) { + domains.push(domain) + } + } + } + await adapter.init(domains, excludeDomains) + } + } + }) + } + private async updateInfo (d: Domain, adapterDomains: Map>, info: DomainInfo): Promise { - const name = this._domains[d] ?? '#default' + const name = this.conf.domains[d] ?? '#default' const adapter = this.adapters.get(name) ?? this.defaultAdapter if (adapter !== undefined) { const h = adapter.helper?.() @@ -129,7 +158,7 @@ export class DbAdapterManagerImpl implements DBAdapterManager { } public getAdapter (domain: Domain, requireExists: boolean): DbAdapter { - const name = this._domains[domain] ?? '#default' + const name = this.conf.domains[domain] ?? '#default' const adapter = this.adapters.get(name) ?? this.defaultAdapter if (adapter === undefined) { throw new Error('adapter not provided: ' + name) diff --git a/server/core/src/types.ts b/server/core/src/types.ts index 55d49ec02a..f6f6e2957a 100644 --- a/server/core/src/types.ts +++ b/server/core/src/types.ts @@ -134,6 +134,8 @@ export interface DBAdapterManager { close: () => Promise registerHelper: (helper: DomainHelper) => Promise + + initAdapters: (ctx: MeasureContext) => Promise } export interface PipelineContext { diff --git a/server/middleware/src/dbAdapter.ts b/server/middleware/src/dbAdapter.ts index 0059f7956e..8ffe917d75 100644 --- a/server/middleware/src/dbAdapter.ts +++ b/server/middleware/src/dbAdapter.ts @@ -77,41 +77,6 @@ export class DBAdapterMiddleware extends BaseMiddleware implements Middleware { } } await txAdapter.init?.(txAdapterDomains) - const model = await txAdapter.getModel(ctx) - - for (const tx of model) { - try { - this.context.hierarchy.tx(tx) - } catch (err: any) { - ctx.warn('failed to apply model transaction, skipping', { tx: JSON.stringify(tx), err }) - } - } - - await ctx.with('init-adapters', {}, async (ctx) => { - for (const [key, adapter] of adapters) { - // already initialized - if (key !== this.conf.domains[DOMAIN_TX] && adapter.init !== undefined) { - let excludeDomains: string[] | undefined - let domains: string[] | undefined - if (this.conf.defaultAdapter === key) { - excludeDomains = [] - for (const domain in this.conf.domains) { - if (this.conf.domains[domain] !== key) { - excludeDomains.push(domain) - } - } - } else { - domains = [] - for (const domain in this.conf.domains) { - if (this.conf.domains[domain] === key) { - domains.push(domain) - } - } - } - await adapter.init(domains, excludeDomains) - } - } - }) const metrics = this.conf.metrics.newChild('📔 server-storage', {}) @@ -127,7 +92,7 @@ export class DBAdapterMiddleware extends BaseMiddleware implements Middleware { // We need to init all next, since we will use model - const adapterManager = new DbAdapterManagerImpl(metrics, this.conf.domains, this.context, defaultAdapter, adapters) + const adapterManager = new DbAdapterManagerImpl(metrics, this.conf, this.context, defaultAdapter, adapters) this.context.adapterManager = adapterManager } diff --git a/server/middleware/src/dbAdapterHelper.ts b/server/middleware/src/dbAdapterHelper.ts index fdead44fb1..1142766b7f 100644 --- a/server/middleware/src/dbAdapterHelper.ts +++ b/server/middleware/src/dbAdapterHelper.ts @@ -20,12 +20,13 @@ import { BaseMiddleware, DomainIndexHelperImpl } from '@hcengineering/server-cor /** * @public */ -export class DBAdapterHelperMiddleware extends BaseMiddleware implements Middleware { +export class DBAdapterInitMiddleware extends BaseMiddleware implements Middleware { static async create ( ctx: MeasureContext, context: PipelineContext, next?: Middleware ): Promise { + await context.adapterManager?.initAdapters?.(ctx) const domainHelper = new DomainIndexHelperImpl(ctx, context.hierarchy, context.modelDb, context.workspace) await context.adapterManager?.registerHelper?.(domainHelper) return undefined diff --git a/server/mongo/src/storage.ts b/server/mongo/src/storage.ts index 48b982d49b..35adf636f6 100644 --- a/server/mongo/src/storage.ts +++ b/server/mongo/src/storage.ts @@ -312,7 +312,9 @@ abstract class MongoAdapterBase implements DbAdapter { } const baseClass = this.hierarchy.getBaseClass(clazz) if (baseClass !== core.class.Doc) { - const classes = this.hierarchy.getDescendants(baseClass).filter((it) => !this.hierarchy.isMixin(it)) + const classes = Array.from( + new Set(this.hierarchy.getDescendants(baseClass).filter((it) => !this.hierarchy.isMixin(it))) + ) // Only replace if not specified if (translatedBase._class === undefined) { @@ -334,7 +336,9 @@ abstract class MongoAdapterBase implements DbAdapter { descendants = descendants.filter((c) => !excludedClassesIds.has(c)) } - const desc = descendants.filter((it: any) => !this.hierarchy.isMixin(it as Ref>)) + const desc = Array.from( + new Set(descendants.filter((it: any) => !this.hierarchy.isMixin(it as Ref>))) + ) translatedBase._class = desc.length === 1 ? desc[0] : { $in: desc } } @@ -348,6 +352,9 @@ abstract class MongoAdapterBase implements DbAdapter { delete translatedBase._class } } + if (translatedBase._class?.$in != null && Array.isArray(translatedBase._class.$in)) { + translatedBase._class.$in = Array.from(new Set(translatedBase._class.$in)) + } if (translatedBase._class?.$in?.length === 1 && translatedBase._class?.$nin === undefined) { translatedBase._class = translatedBase._class.$in[0] } diff --git a/server/server-pipeline/src/pipeline.ts b/server/server-pipeline/src/pipeline.ts index d6aec71b4f..f777844232 100644 --- a/server/server-pipeline/src/pipeline.ts +++ b/server/server-pipeline/src/pipeline.ts @@ -18,7 +18,7 @@ import { BroadcastMiddleware, ConfigurationMiddleware, ContextNameMiddleware, - DBAdapterHelperMiddleware, + DBAdapterInitMiddleware, DBAdapterMiddleware, DomainFindMiddleware, DomainTxMiddleware, @@ -135,7 +135,7 @@ export function createServerPipeline ( LiveQueryMiddleware.create, DomainFindMiddleware.create, DomainTxMiddleware.create, - DBAdapterHelperMiddleware.create, + DBAdapterInitMiddleware.create, ModelMiddleware.create, DBAdapterMiddleware.create(conf), // Configure DB adapters BroadcastMiddleware.create(broadcast) diff --git a/server/server/src/sessionManager.ts b/server/server/src/sessionManager.ts index 118c18b9e6..e5c7e29627 100644 --- a/server/server/src/sessionManager.ts +++ b/server/server/src/sessionManager.ts @@ -48,6 +48,9 @@ import { } from './types' import { sendResponse } from './utils' +const ticksPerSecond = 20 +const workspaceSoftShutdownTicks = 3 * ticksPerSecond + interface WorkspaceLoginInfo extends Omit { upgrade?: { toProcess: number @@ -75,7 +78,7 @@ function timeoutPromise (time: number): { promise: Promise, cancelHandle: */ export interface Timeouts { // Timeout preferences - pingTimeout: number // Default 1 second + pingTimeout: number // Default 10 second reconnectTimeout: number // Default 3 seconds } @@ -113,8 +116,8 @@ class TSessionManager implements SessionManager { } ) { this.checkInterval = setInterval(() => { - this.handleInterval() - }, timeouts.pingTimeout) + this.handleTick() + }, 1000 / ticksPerSecond) } scheduleMaintenance (timeMinutes: number): void { @@ -168,47 +171,58 @@ class TSessionManager implements SessionManager { ticks = 0 - handleInterval (): void { + handleTick (): void { for (const [wsId, workspace] of this.workspaces.entries()) { for (const s of workspace.sessions) { - if (this.ticks % (5 * 60) === 0) { + if (this.ticks % (5 * 60 * ticksPerSecond) === 0) { s[1].session.mins5.find = s[1].session.current.find s[1].session.mins5.tx = s[1].session.current.tx s[1].session.current = { find: 0, tx: 0 } } const now = Date.now() - const diff = now - s[1].session.lastRequest + const lastRequestDiff = now - s[1].session.lastRequest let timeout = 60000 if (s[1].session.getUser() === systemAccountEmail) { timeout = timeout * 10 } - if (diff > timeout && this.ticks % 10 === 0) { - this.ctx.warn('session hang, closing...', { wsId, user: s[1].session.getUser() }) + const isCurrentUserTick = this.ticks % ticksPerSecond === s[1].tickHash - // Force close workspace if only one client and it hang. - void this.close(this.ctx, s[1].socket, wsId) - continue - } - if (diff > 20000 && diff < 60000 && this.ticks % 10 === 0) { - s[1].socket.send(workspace.context, { result: 'ping' }, s[1].session.binaryMode, s[1].session.useCompression) - } + if (isCurrentUserTick) { + if (lastRequestDiff > timeout) { + this.ctx.warn('session hang, closing...', { wsId, user: s[1].session.getUser() }) - for (const r of s[1].session.requests.values()) { - if (now - r.start > 30000) { - this.ctx.warn('request hang found, 30sec', { - wsId, - user: s[1].session.getUser(), - ...r.params - }) + // Force close workspace if only one client and it hang. + void this.close(this.ctx, s[1].socket, wsId) + continue + } + if (lastRequestDiff + (1 / 10) * lastRequestDiff > this.timeouts.pingTimeout) { + // We need to check state and close socket if it broken + if (s[1].socket.checkState()) { + s[1].socket.send( + workspace.context, + { result: 'ping' }, + s[1].session.binaryMode, + s[1].session.useCompression + ) + } + } + for (const r of s[1].session.requests.values()) { + if (now - r.start > 30000) { + this.ctx.warn('request hang found, 30sec', { + wsId, + user: s[1].session.getUser(), + ...r.params + }) + } } } } // Wait some time for new client to appear before closing workspace. - if (workspace.sessions.size === 0 && workspace.closing === undefined) { + if (workspace.sessions.size === 0 && workspace.closing === undefined && workspace.workspaceInitCompleted) { workspace.softShutdown-- if (workspace.softShutdown <= 0) { this.ctx.warn('closing workspace, no users', { @@ -219,7 +233,7 @@ class TSessionManager implements SessionManager { workspace.closing = this.performWorkspaceCloseCheck(workspace, workspace.workspaceId, wsId) } } else { - workspace.softShutdown = 3 + workspace.softShutdown = workspaceSoftShutdownTicks } if (this.clientErrors !== this.oldClientErrors) { @@ -270,6 +284,8 @@ class TSessionManager implements SessionManager { } } + sessionCounter = 0 + @withContext('📲 add-session') async addSession ( ctx: MeasureContext, @@ -423,7 +439,13 @@ class TSessionManager implements SessionManager { session.sessionInstanceId = generateId() this.sessions.set(ws.id, { session, socket: ws }) // We need to delete previous session with Id if found. - workspace.sessions.set(session.sessionId, { session, socket: ws }) + this.sessionCounter++ + workspace.sessions.set(session.sessionId, { session, socket: ws, tickHash: this.sessionCounter % ticksPerSecond }) + + // Mark workspace as init completed and we had at least one client. + if (!workspace.workspaceInitCompleted) { + workspace.workspaceInitCompleted = true + } // We do not need to wait for set-status, just return session to client const _workspace = workspace @@ -593,11 +615,12 @@ class TSessionManager implements SessionManager { branding ), sessions: new Map(), - softShutdown: 3, + softShutdown: workspaceSoftShutdownTicks, upgrade, workspaceId: token.workspace, workspaceName, - branding + branding, + workspaceInitCompleted: false } this.workspaces.set(toWorkspaceString(token.workspace), workspace) return workspace diff --git a/server/server/src/types.ts b/server/server/src/types.ts index f6ee0fbbec..385971678b 100644 --- a/server/server/src/types.ts +++ b/server/server/src/types.ts @@ -93,6 +93,8 @@ export interface ConnectionSocket { data: () => Record readRequest: (buffer: Buffer, binary: boolean) => Request + + checkState: () => boolean } /** @@ -114,11 +116,12 @@ export interface Workspace { context: MeasureContext id: string pipeline: Promise - sessions: Map + sessions: Map upgrade: boolean closing?: Promise softShutdown: number + workspaceInitCompleted: boolean workspaceId: WorkspaceId workspaceName: string diff --git a/server/ws/src/server_http.ts b/server/ws/src/server_http.ts index c18450d254..4a297dafa3 100644 --- a/server/ws/src/server_http.ts +++ b/server/ws/src/server_http.ts @@ -310,7 +310,9 @@ export function startHttpServer ( } : false, skipUTF8Validation: true, - maxPayload: 250 * 1024 * 1024 + maxPayload: 250 * 1024 * 1024, + backlog: 1000, + clientTracking: false // We do not need to track clients inside clients. }) // eslint-disable-next-line @typescript-eslint/no-misused-promises const handleConnection = async ( @@ -492,6 +494,14 @@ function createWebsocketClientSocket ( close: () => { cs.isClosed = true ws.close() + ws.terminate() + }, + checkState: () => { + if (ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) { + ws.terminate() + return false + } + return true }, readRequest: (buffer: Buffer, binary: boolean) => { return rpcHandler.readRequest(buffer, binary) diff --git a/tests/sanity/tests/chat/chat.spec.ts b/tests/sanity/tests/chat/chat.spec.ts index 006ae50416..de13936120 100644 --- a/tests/sanity/tests/chat/chat.spec.ts +++ b/tests/sanity/tests/chat/chat.spec.ts @@ -6,6 +6,7 @@ import { SignUpData } from '../model/common-types' import { LeftSideMenuPage } from '../model/left-side-menu-page' import { LoginPage } from '../model/login-page' import { SelectWorkspacePage } from '../model/select-workspace-page' +import { SidebarPage } from '../model/sidebar-page' import { PlatformURI, generateTestData, @@ -19,6 +20,7 @@ test.describe('Channel tests', () => { let leftSideMenuPage: LeftSideMenuPage let chunterPage: ChunterPage let channelPage: ChannelPage + let sidebarPage: SidebarPage let loginPage: LoginPage let api: ApiEndpoint let newUser2: SignUpData @@ -32,6 +34,7 @@ test.describe('Channel tests', () => { chunterPage = new ChunterPage(page) channelPage = new ChannelPage(page) loginPage = new LoginPage(page) + sidebarPage = new SidebarPage(page) api = new ApiEndpoint(request) await api.createAccount(data.userName, '1234', data.firstName, data.lastName) await api.createWorkspaceWithLogin(data.workspaceName, data.userName, '1234') @@ -395,7 +398,7 @@ test.describe('Channel tests', () => { await channelPageSecond.checkMessageExist(`@${mentionName}`, true, `@${mentionName}`) }) - test('User can star and unstar a channel', async () => { + test('User is able to star and unstar a channel', async () => { await test.step('Prepare channel', async () => { await leftSideMenuPage.clickChunter() await chunterPage.clickChannelBrowser() @@ -419,7 +422,7 @@ test.describe('Channel tests', () => { }) }) - test('User can leave and join a channel', async () => { + test('User is able to leave and join a channel', async () => { await test.step('Prepare channel', async () => { await leftSideMenuPage.clickChunter() await chunterPage.clickChannelBrowser() @@ -461,7 +464,7 @@ test.describe('Channel tests', () => { }) }) - test('User can filter channels in table', async () => { + test('User is able to filter channels in table', async () => { await test.step('Prepare channel', async () => { await leftSideMenuPage.clickChunter() await chunterPage.clickChannelBrowser() @@ -486,7 +489,7 @@ test.describe('Channel tests', () => { }) }) - test('User can search channel in table', async () => { + test('User is able to search channel in table', async () => { await test.step('Prepare channel', async () => { await leftSideMenuPage.clickChunter() await chunterPage.clickChannelBrowser() @@ -512,4 +515,110 @@ test.describe('Channel tests', () => { await channelPage.checkIfChannelTableExist('general', true) }) }) + + test('User is able to work with a channel in a sidebar', async () => { + await test.step('Prepare channel', async () => { + await leftSideMenuPage.clickChunter() + await chunterPage.clickChannelBrowser() + await chunterPage.clickNewChannelHeader() + await chunterPage.createPrivateChannel(data.channelName, false) + await channelPage.checkIfChannelDefaultExist(true, data.channelName) + + await leftSideMenuPage.clickChunter() + await channelPage.clickChooseChannel(data.channelName) + await channelPage.sendMessage('Test message') + }) + + await test.step('Open channel in sidebar', async () => { + await sidebarPage.checkIfSidebarPageButtonIsExist(false, 'chat') + await channelPage.makeActionWithChannelInMenu(data.channelName, 'Open in sidebar') + + await sidebarPage.checkIfSidebarPageButtonIsExist(true, 'chat') + await sidebarPage.checkIfSidebarHasVerticalTab(true, data.channelName) + await sidebarPage.checkIfSidebarIsOpen(true) + await sidebarPage.checkIfChatSidebarTabIsOpen(true, data.channelName) + }) + + await test.step('Go to another page and check if sidebar will be keeping', async () => { + await leftSideMenuPage.clickTracker() + await sidebarPage.checkIfSidebarIsOpen(true) + await sidebarPage.checkIfChatSidebarTabIsOpen(true, data.channelName) + await leftSideMenuPage.clickChunter() + }) + + await test.step('Close channel in sidebar', async () => { + await sidebarPage.closeOpenedVerticalTab() + await sidebarPage.checkIfSidebarHasVerticalTab(false, data.channelName) + await sidebarPage.checkIfSidebarIsOpen(false) + }) + + await test.step('Reopen channel in sidebar', async () => { + await channelPage.makeActionWithChannelInMenu(data.channelName, 'Open in sidebar') + await sidebarPage.checkIfSidebarHasVerticalTab(true, data.channelName) + await sidebarPage.checkIfChatSidebarTabIsOpen(true, data.channelName) + }) + + await test.step('Open general in sidebar too', async () => { + await channelPage.makeActionWithChannelInMenu('general', 'Open in sidebar') + await sidebarPage.checkIfSidebarHasVerticalTab(true, data.channelName) + await sidebarPage.checkIfSidebarHasVerticalTab(true, 'general') + await sidebarPage.checkIfChatSidebarTabIsOpen(true, 'general') + }) + + await test.step('Pin and unpin channel tab', async () => { + await sidebarPage.pinVerticalTab(data.channelName) + await sidebarPage.checkIfVerticalTabIsPinned(true, data.channelName) + + await sidebarPage.unpinVerticalTab(data.channelName) + await sidebarPage.checkIfVerticalTabIsPinned(false, data.channelName) + }) + + await test.step('Close sidebar tab by close button in vertical tab', async () => { + await sidebarPage.clickVerticalTab(data.channelName) + await sidebarPage.closeVerticalTabByCloseButton(data.channelName) + await sidebarPage.checkIfSidebarHasVerticalTab(false, data.channelName) + await sidebarPage.checkIfChatSidebarTabIsOpen(true, 'general') + }) + + await test.step('Close sidebar tab by context menu', async () => { + await channelPage.makeActionWithChannelInMenu('random', 'Open in sidebar') + await sidebarPage.closeVerticalTabByRightClick('random') + await sidebarPage.checkIfSidebarHasVerticalTab(false, 'random') + await sidebarPage.checkIfChatSidebarTabIsOpen(true, 'general') + }) + + await test.step('Close the last channel tab in Sidebar', async () => { + await sidebarPage.closeVerticalTabByCloseButton('general') + await sidebarPage.checkIfSidebarIsOpen(false) + await sidebarPage.checkIfSidebarPageButtonIsExist(false, 'chat') + }) + }) + + test('User is able to create thread automatically in Sidebar', async () => { + await test.step('Prepare channel', async () => { + await leftSideMenuPage.clickChunter() + await chunterPage.clickChannelBrowser() + await chunterPage.clickNewChannelHeader() + await chunterPage.createPrivateChannel(data.channelName, false) + await channelPage.checkIfChannelDefaultExist(true, data.channelName) + + await leftSideMenuPage.clickChunter() + await channelPage.clickChooseChannel(data.channelName) + await channelPage.sendMessage('Test message') + }) + + await test.step('Open channel in Sidebar', async () => { + await channelPage.replyToMessage('Test message', 'Reply message') + + await sidebarPage.checkIfSidebarIsOpen(true) + await sidebarPage.checkIfSidebarHasVerticalTab(true, data.channelName) + await sidebarPage.checkIfChatSidebarTabIsOpen(true, 'Thread') + await sidebarPage.checkIfChatSidebarTabIsOpen(true, data.channelName) + }) + + await test.step('User go to another chat and Sidebar with tread disappears', async () => { + await channelPage.clickChannel('random') + await sidebarPage.checkIfSidebarIsOpen(false) + }) + }) }) diff --git a/tests/sanity/tests/documents/documents-content.spec.ts b/tests/sanity/tests/documents/documents-content.spec.ts index 6b5bf05eba..11d0023565 100644 --- a/tests/sanity/tests/documents/documents-content.spec.ts +++ b/tests/sanity/tests/documents/documents-content.spec.ts @@ -151,7 +151,7 @@ test.describe('Content in the Documents tests', () => { }) test.describe('Image in the document', () => { - test('Check Image alignment setting', async ({ page }) => { + test('Check image alignment setting', async ({ page }) => { await documentContentPage.addImageToDocument(page) await test.step('Align image to right', async () => { @@ -170,9 +170,8 @@ test.describe('Content in the Documents tests', () => { }) }) - test.skip('Check Image view and size actions', async ({ page }) => { + test.skip('Check Image size manipulations', async ({ page }) => { await documentContentPage.addImageToDocument(page) - const imageSrc = await documentContentPage.firstImageInDocument().getAttribute('src') await test.step('Set size of image to the 25%', async () => { await documentContentPage.clickImageSizeButton('25%') @@ -194,6 +193,11 @@ test.describe('Content in the Documents tests', () => { await documentContentPage.clickImageSizeButton('Unset') await documentContentPage.checkImageSize(IMAGE_ORIGINAL_SIZE) }) + }) + + test('Check Image views', async ({ page, context }) => { + await documentContentPage.addImageToDocument(page) + const imageSrc = await documentContentPage.firstImageInDocument().getAttribute('src') await test.step('User can open image in fullscreen on current page', async () => { await documentContentPage.clickImageFullscreenButton() @@ -203,12 +207,11 @@ test.describe('Content in the Documents tests', () => { }) await test.step('User can open image original in the new tab', async () => { - const [newPage] = await Promise.all([ - page.waitForEvent('popup'), - documentContentPage.clickImageOriginalButton() - ]) + const pagePromise = context.waitForEvent('page') + await documentContentPage.clickImageOriginalButton() + const newPage = await pagePromise - await newPage.waitForLoadState('domcontentloaded') + await newPage.waitForLoadState() expect(newPage.url()).toBe(imageSrc) await newPage.close() }) diff --git a/tests/sanity/tests/model/common-page.ts b/tests/sanity/tests/model/common-page.ts index ea36065161..55da551ee1 100644 --- a/tests/sanity/tests/model/common-page.ts +++ b/tests/sanity/tests/model/common-page.ts @@ -16,7 +16,7 @@ export class CommonPage { selectPopupButton = (): Locator => this.page.locator('div.selectPopup button') selectPopupExpandButton = (): Locator => this.page.locator('div.selectPopup button[data-id="btnExpand"]') popupSpanLabel = (point: string): Locator => - this.page.locator('div[class$="opup"] span[class*="label"]', { hasText: point }) + this.page.locator(`div[class$="opup"] span[class*="label"]:has-text("${point}")`) readonly inputSearchIcon = (): Locator => this.page.locator('.searchInput-icon') diff --git a/tests/sanity/tests/model/planning/planning-page.ts b/tests/sanity/tests/model/planning/planning-page.ts index c7f89e2d1f..06f724383b 100644 --- a/tests/sanity/tests/model/planning/planning-page.ts +++ b/tests/sanity/tests/model/planning/planning-page.ts @@ -16,6 +16,7 @@ export class PlanningPage extends CalendarPage { private readonly panel = (): Locator => this.page.locator('div.hulyModal-container') private readonly toDosContainer = (): Locator => this.page.locator('div.toDos-container') private readonly schedule = (): Locator => this.page.locator('div.hulyComponent.modal') + private readonly sidebarSchedule = (): Locator => this.page.locator('#sidebar .calendar-container') readonly pageHeader = (): Locator => this.page.locator('div[class*="navigator"] div[class*="header"]', { hasText: 'Planning' }) @@ -81,6 +82,9 @@ export class PlanningPage extends CalendarPage { readonly eventInSchedule = (title: string): Locator => this.schedule().locator('div.event-container', { hasText: title }) + readonly eventInSidebarSchedule = (title: string): Locator => + this.sidebarSchedule().locator('div.event-container', { hasText: title }) + readonly toDoInToDos = (hasText: string): Locator => this.toDosContainer().locator('button.hulyToDoLine-container', { hasText }) diff --git a/tests/sanity/tests/model/recruiting/talent-details-page.ts b/tests/sanity/tests/model/recruiting/talent-details-page.ts index a71e0918dd..403e2a7413 100644 --- a/tests/sanity/tests/model/recruiting/talent-details-page.ts +++ b/tests/sanity/tests/model/recruiting/talent-details-page.ts @@ -19,12 +19,13 @@ export class TalentDetailsPage extends CommonRecruitingPage { readonly buttonMergeContacts = (): Locator => this.page.locator('button[class*="menuItem"] span', { hasText: 'Merge contacts' }) - readonly buttonFinalContact = (): Locator => - this.page.locator('form[id="contact:string:MergePersons"] button', { hasText: 'Final contact' }) + readonly formMergeContacts = (): Locator => this.page.locator('form[id="contact:string:MergePersons"]') - readonly buttonMergeRow = (): Locator => this.page.locator('form[id="contact:string:MergePersons"] div.box') + readonly buttonFinalContact = (): Locator => this.formMergeContacts().locator('button', { hasText: 'Final contact' }) + + readonly buttonMergeRow = (): Locator => this.formMergeContacts().locator('div.box') readonly buttonPopupMergeContacts = (): Locator => - this.page.locator('form[id="contact:string:MergePersons"] button > span', { hasText: 'Merge contacts' }) + this.formMergeContacts().locator('button:has-text("Merge contacts")') readonly textAttachmentName = (): Locator => this.page.locator('div.name a') readonly titleAndSourceTalent = (title: string): Locator => this.page.locator('button > span', { hasText: title }) @@ -42,6 +43,12 @@ export class TalentDetailsPage extends CommonRecruitingPage { await expect(this.textTagItem().first()).toContainText(skillTag) } + async enterLocation (location: string): Promise { + const input = this.inputLocation() + await input.click() + await input.fill(location) + } + async addTitle (title: string): Promise { await this.buttonInputTitle().click() await this.fillToSelectPopup(this.page, title) @@ -97,8 +104,8 @@ export class TalentDetailsPage extends CommonRecruitingPage { } } - async checkMergeContacts (talentName: string, title: string, source: string): Promise { - await expect(this.page.locator('div.location input')).toHaveValue(talentName) + async checkMergeContacts (location: string, title: string, source: string): Promise { + await expect(this.page.locator('div.location input')).toHaveValue(location) await expect(this.titleAndSourceTalent(title)).toBeVisible() await expect(this.titleAndSourceTalent(source)).toBeVisible() } diff --git a/tests/sanity/tests/model/sidebar-page.ts b/tests/sanity/tests/model/sidebar-page.ts new file mode 100644 index 0000000000..aec2d75739 --- /dev/null +++ b/tests/sanity/tests/model/sidebar-page.ts @@ -0,0 +1,120 @@ +import { expect, Locator, Page } from '@playwright/test' +import { CommonPage } from './common-page' + +export type SidebarTabTypes = 'calendar' | 'office' | 'chat' + +export class SidebarPage extends CommonPage { + readonly page: Page + + constructor (page: Page) { + super(page) + this.page = page + } + + sidebar = (): Locator => this.page.locator('#sidebar') + content = (): Locator => this.sidebar().locator('.content') + contentHeaderByTitle = (title: string): Locator => + this.content().locator(`.hulyHeader-titleGroup:has-text("${title}")`) + + contentCloseButton = (): Locator => this.content().locator('.hulyHeader-container button.iconOnly').last() + + calendarSidebarButton = (): Locator => this.sidebar().locator('button[id$="Calendar"]') + officeSidebarButton = (): Locator => this.sidebar().locator('button[id$="Office"]') + chatSidebarButton = (): Locator => this.sidebar().locator('button[id$="Chat"]') + + verticalTabs = (): Locator => this.sidebar().locator('.tabs').locator('.container') + verticalTabByName = (name: string): Locator => + this.sidebar().locator('.tabs').locator(`.container:has-text("${name}")`) + + verticalTabCloseButton = (name: string): Locator => this.verticalTabByName(name).locator('.close-button button') + currentYear = new Date().getFullYear().toString() + + plannerSidebarNextDayButton = (): Locator => + this.sidebar().locator('.hulyHeader-buttonsGroup').getByRole('button').last() + + // buttonOpenChannelInSidebar = + + // Actions + async checkIfSidebarIsOpen (isOpen: boolean): Promise { + await expect(this.content()).toBeVisible({ visible: isOpen }) + } + + async checkIfSidebarHasVerticalTab (isExist: boolean, tabName: string): Promise { + await expect(this.verticalTabByName(tabName)).toBeVisible({ visible: isExist }) + } + + async clickVerticalTab (tabName: string): Promise { + await this.verticalTabByName(tabName).click() + } + + async closeVerticalTabByCloseButton (tabName: string): Promise { + await this.verticalTabCloseButton(tabName).click() + } + + async closeOpenedVerticalTab (): Promise { + await this.contentCloseButton().click() + } + + async pinVerticalTab (tabName: string): Promise { + await this.verticalTabByName(tabName).click({ button: 'right' }) + await this.page.locator('.popup').locator('button:has-text("Pin")').click() + } + + async unpinVerticalTab (tabName: string): Promise { + await this.verticalTabByName(tabName).click({ button: 'right' }) + await this.page.locator('.popup').locator('button:has-text("Unpin")').click() + } + + async closeVerticalTabByRightClick (tabName: string): Promise { + await this.verticalTabByName(tabName).click({ button: 'right' }) + await this.page.locator('.popup').locator('button:has-text("Close")').click() + } + + async checkIfVerticalTabIsPinned (needBePinned: boolean, tabName: string): Promise { + await expect(this.verticalTabCloseButton(tabName)).toBeVisible({ visible: !needBePinned }) + } + + async checkNumberOfVerticalTabs (count: number): Promise { + await expect(this.verticalTabs()).toHaveCount(count) + } + + async checkIfPlanerSidebarTabIsOpen (isExist: boolean): Promise { + await expect(this.contentHeaderByTitle(this.currentYear)).toBeVisible({ visible: isExist }) + } + + async checkIfChatSidebarTabIsOpen (isExist: boolean, channelName: string): Promise { + await expect(this.contentHeaderByTitle(channelName)).toBeVisible({ visible: isExist }) + } + + async checkIfOfficeSidebarTabIsOpen (isExist: boolean, channelName: string): Promise { + await expect(this.contentHeaderByTitle('Office')).toBeVisible({ visible: isExist }) + } + + async checkIfSidebarPageButtonIsExist (isExist: boolean, type: SidebarTabTypes): Promise { + switch (type) { + case 'chat': + await expect(this.chatSidebarButton()).toBeVisible({ visible: isExist }) + break + case 'office': + await expect(this.officeSidebarButton()).toBeVisible({ visible: isExist }) + break + case 'calendar': + await expect(this.calendarSidebarButton()).toBeVisible({ visible: isExist }) + break + } + } + + async clickSidebarPageButton (type: SidebarTabTypes): Promise { + switch (type) { + case 'chat': + await this.chatSidebarButton().click() + break + case 'office': + await this.officeSidebarButton().click() + break + case 'calendar': + await this.calendarSidebarButton().click() + break + } + } +} diff --git a/tests/sanity/tests/planning/plan.spec.ts b/tests/sanity/tests/planning/plan.spec.ts index f16a0c7a88..79fd0ece1a 100644 --- a/tests/sanity/tests/planning/plan.spec.ts +++ b/tests/sanity/tests/planning/plan.spec.ts @@ -1,5 +1,13 @@ import { test } from '@playwright/test' -import { generateId, PlatformSetting, PlatformURI, generateTestData, getTimeForPlanner } from '../utils' +import { + generateId, + PlatformSetting, + PlatformURI, + generateTestData, + getTimeForPlanner, + getSecondPageByInvite, + getInviteLink +} from '../utils' import { PlanningPage } from '../model/planning/planning-page' import { NewToDo } from '../model/planning/types' import { PlanningNavigationMenuPage } from '../model/planning/planning-navigation-menu-page' @@ -9,9 +17,10 @@ import { faker } from '@faker-js/faker' import { LeftSideMenuPage } from '../model/left-side-menu-page' import { ApiEndpoint } from '../API/Api' import { LoginPage } from '../model/login-page' -import { SignInJoinPage } from '../model/signin-page' +import { SidebarPage } from '../model/sidebar-page' import { TeamPage } from '../model/team-page' import { SelectWorkspacePage } from '../model/select-workspace-page' +import { ChannelPage } from '../model/channel-page' test.use({ storageState: PlatformSetting @@ -171,6 +180,9 @@ test.describe('Planning ToDo tests', () => { test('Adding ToDo by dragging and checking visibility in the Team Planner', async ({ browser, page, request }) => { const data: TestData = generateTestData() + const leftMenuPage = new LeftSideMenuPage(page) + const channelPage = new ChannelPage(page) + const newUser2: SignUpData = { firstName: faker.person.firstName(), lastName: faker.person.lastName(), @@ -213,16 +225,11 @@ test.describe('Planning ToDo tests', () => { await planningPage.buttonPopupOnlyVisibleToYou().click() await planningPage.buttonPopupSave().click() - await leftSideMenuPage.openProfileMenu() - await leftSideMenuPage.inviteToWorkspace() - await leftSideMenuPage.getInviteLink() - const linkText = await page.locator('.antiPopup .link').textContent() - const page2 = await browser.newPage() - const leftSideMenuPageSecond = new LeftSideMenuPage(page2) + const linkText = await getInviteLink(page) await api.createAccount(newUser2.email, newUser2.password, newUser2.firstName, newUser2.lastName) - await page2.goto(linkText ?? '') - const joinPage = new SignInJoinPage(page2) - await joinPage.join(newUser2) + using _page2 = await getSecondPageByInvite(browser, linkText, newUser2) + const page2 = _page2.page + const leftSideMenuPageSecond = new LeftSideMenuPage(page2) await leftSideMenuPageSecond.clickTeam() const teamPage = new TeamPage(page2) @@ -234,6 +241,40 @@ test.describe('Planning ToDo tests', () => { .locator('div.item', { hasText: 'Busy 30m' }) .isVisible() - await page2.close() + await test.step('Go to another page to check work in Sidebar', async () => { + await leftMenuPage.clickChunter() + await channelPage.clickChannel('general') + }) + + const sidebarPage = new SidebarPage(page) + + await test.step('Check visibility of task in sidebar planner', async () => { + await sidebarPage.clickSidebarPageButton('calendar') + await sidebarPage.checkIfPlanerSidebarTabIsOpen(true) + }) + + await test.step('Change event title from sidebar calendar', async () => { + await sidebarPage.plannerSidebarNextDayButton().click() + await planningPage.eventInSidebarSchedule(titleV).click() + await planningPage.buttonPopupCreateVisible().click() + await planningPage.buttonPopupOnlyVisibleToYou().click() + await planningPage.buttonPopupSave().click() + }) + }) + + test('User is able to open Planner in Sidebar', async ({ browser, page, request }) => { + const leftMenuPage = new LeftSideMenuPage(page) + const channelPage = new ChannelPage(page) + + await test.step('Go to any another page', async () => { + await leftMenuPage.clickChunter() + await channelPage.clickChannel('general') + }) + + await test.step('Open planner via sidebar icon button', async () => { + const sidebarPage = new SidebarPage(page) + await sidebarPage.clickSidebarPageButton('calendar') + await sidebarPage.checkIfPlanerSidebarTabIsOpen(true) + }) }) }) diff --git a/tests/sanity/tests/recruiting/talents.spec.ts b/tests/sanity/tests/recruiting/talents.spec.ts index 92fb05832b..50c47afae3 100644 --- a/tests/sanity/tests/recruiting/talents.spec.ts +++ b/tests/sanity/tests/recruiting/talents.spec.ts @@ -74,11 +74,15 @@ test.describe('candidate/talents tests', () => { }) test('Merge contacts', async () => { + const firstLocation = 'Location 1' + const secondLocation = 'Location 2' + await navigationMenuPage.clickButtonTalents() // talent1 const talentNameFirst = await talentsPage.createNewTalent() await talentsPage.openTalentByTalentName(talentNameFirst) - await talentDetailsPage.inputLocation().fill('Awesome Location Merge1') + + await talentDetailsPage.enterLocation(firstLocation) const titleTalent1 = 'TitleMerge1' await talentDetailsPage.addTitle(titleTalent1) const sourceTalent1 = 'SourceTalent1' @@ -89,7 +93,8 @@ test.describe('candidate/talents tests', () => { await navigationMenuPage.clickButtonTalents() const talentNameSecond = await talentsPage.createNewTalent() await talentsPage.openTalentByTalentName(talentNameSecond) - await talentDetailsPage.inputLocation().fill('Awesome Location Merge2') + + await talentDetailsPage.enterLocation(secondLocation) const titleTalent2 = 'TitleMerge2' await talentDetailsPage.addTitle(titleTalent2) const sourceTalent2 = 'SourceTalent2' @@ -104,7 +109,7 @@ test.describe('candidate/talents tests', () => { finalContactName: talentNameSecond.lastName, name: `${talentNameFirst.lastName} ${talentNameFirst.firstName}`, mergeLocation: true, - location: 'Awesome Location Merge1', + location: firstLocation, mergeTitle: true, title: titleTalent1, mergeSource: true, @@ -116,7 +121,7 @@ test.describe('candidate/talents tests', () => { await talentsPage.openTalentByTalentName(talentNameFirst) await talentDetailsPage.checkSocialLinks('Phone', '123123213213') await talentDetailsPage.checkSocialLinks('Email', 'test-merge-2@gmail.com') - await talentDetailsPage.checkMergeContacts('Awesome Location Merge1', titleTalent2, sourceTalent2) + await talentDetailsPage.checkMergeContacts(firstLocation, titleTalent2, sourceTalent2) }) test('Match to vacancy', async () => {