diff --git a/packages/k6-tests/src/clients/cool/client.ts b/packages/k6-tests/src/clients/cool/client.ts new file mode 100644 index 0000000..548c9af --- /dev/null +++ b/packages/k6-tests/src/clients/cool/client.ts @@ -0,0 +1,88 @@ +import { sleep } from 'k6' +import { ErrorEvent } from 'k6/experimental/websockets' + +import { FileInfo, UserAuth } from './info' +import { EngineType, MessageType, Session, SocketType } from './io' +import { docsWorker } from './worker' + +export class Client { + // @ts-ignore + private session: Session + + private token: string + + private documentId: string + + private userAuth: UserAuth + + private fileInfo: FileInfo + + private wopiSrc: string + + constructor(p: { url: string, access_token: string, documentId: string, userAuth: UserAuth, fileInfo: FileInfo }) { + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { access_token, access_token_ttl } = p + + const appUrl = new URL(p.url) + const wopiSrc = app_url.searchParams.get('WOPISrc') + + const wopiUrl = new URL(wopiSrc) + wopiUrl.searchParams.set('access_token', access_token) + wopiUrl.searchParams.set('access_token_ttl', access_token_ttl) + + const wssUrl = new URL(`wss://${appUrl.hostname}:${app_url.port}/cool/${encodeURIComponent(wopiUrl)}/ws`) + wssUrl.searchParams.set('WOPISrc', wopiSrc) + wssUrl.searchParams.set('compat', '/ws') + + this.token = p.token + this.documentId = p.documentId + this.userAuth = p.userAuth + this.fileInfo = p.fileInfo + this.wopiSrc = wopiSrc + this.session = new Session({ url: wss_url }) + } + + onError(h: (event?: ErrorEvent) => void) { + this.session.onError(h) + } + + async establishSession(): Promise { + await docsWorker({ session: this.session }) + await this.session.waitFor({ engineType: EngineType.enum.open }) + await this.session.publish({ data: `load url=${this.wopiSrc} accessibilityState=false` + + ' deviceFormFactor=desktop darkTheme=false timezone=America/Montreal' }) + await this.session.waitFor({ engineType: EngineType.enum.message, socketType: SocketType.enum.connect }) + } + + async makeChanges(p: { changes: Array }) { + await this.session.publish({ data: isSaveLockMessage() }) + const { messageData: { saveLock: isLocked } } = await this.session.waitFor({ + engineType: EngineType.enum.message, + socketType: SocketType.enum.event, + messageType: MessageType.enum.saveLock + }) + + if (isLocked) { + sleep(0.5) + await this.makeChanges(p) + return + } + + p.changes.forEach(async (change) => { + await this.session.publish({ data: change }) + }) + + await this.session.publish({ data: 'save dontTerminateEdit=0 dontSaveIfUnmodified=0' }) + + await this.session.waitFor({ + engineType: EngineType.enum.message, + socketType: SocketType.enum.event, + messageType: MessageType.enum.unSaveLock + }) + } + + disconnect() { + this.session.disconnect() + } +} diff --git a/packages/k6-tests/src/clients/cool/index.ts b/packages/k6-tests/src/clients/cool/index.ts new file mode 100644 index 0000000..e1868d9 --- /dev/null +++ b/packages/k6-tests/src/clients/cool/index.ts @@ -0,0 +1,2 @@ +export { Client } from './client' +export { obtainDocumentInformation } from './info' diff --git a/packages/k6-tests/src/clients/cool/info.ts b/packages/k6-tests/src/clients/cool/info.ts new file mode 100644 index 0000000..b4ce123 --- /dev/null +++ b/packages/k6-tests/src/clients/cool/info.ts @@ -0,0 +1,67 @@ +import { Client } from '@ownclouders/k6-tdk/lib/client' +import { objectToQueryString } from '@ownclouders/k6-tdk/lib/utils' +import { z } from 'zod' + +const UserAuth = z.object({ + wopiSrc: z.string(), + access_token: z.string(), + access_token_ttl: z.number(), + userSessionId: z.string(), + mode: z.string() +}) +// eslint-disable-next-line @typescript-eslint/no-redeclare +export type UserAuth = z.infer + +const FileInfo = z.object({ + BaseFileName: z.string(), + BreadcrumbDocName: z.string(), + HostEditUrl: z.string(), + HostViewUrl: z.string(), + BreadcrumbFolderUrl: z.string(), + IsAnonymousUser: z.boolean(), + UserFriendlyName: z.string(), + BreadcrumbFolderName: z.string(), + DownloadUrl: z.string(), + FileUrl: z.string(), + BreadcrumbBrandName: z.string(), + BreadcrumbBrandUrl: z.string(), + OwnerId: z.string(), + UserId: z.string(), + Size: z.number(), + Version: z.string(), + SupportsExtendedLockLength: z.boolean(), + SupportsGetLock: z.boolean(), + SupportsUpdate: z.boolean(), + UserCanWrite: z.boolean(), + SupportsLocks: z.boolean(), + SupportsDeleteFile: z.boolean(), + UserCanNotWriteRelative: z.boolean(), + SupportsRename: z.boolean(), + UserCanRename: z.boolean(), + SupportsContainers: z.boolean(), + SupportsUserInfo: z.boolean() +}) +// eslint-disable-next-line @typescript-eslint/no-redeclare +export type FileInfo = z.infer + +export const obtainDocumentInformation = async (p: { + client: Client, + resourceId: string, + appName: string +}) => { + const openRequestParams = { file_id: p.resourceId, lang: 'de', app_name: p.appName } + const appOpenResponse = p.client.httpClient<'text'>( + 'POST', + `/app/open?${objectToQueryString(openRequestParams)}`, + JSON.stringify(openRequestParams) + ) + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { app_url, form_parameters: { access_token, access_token_ttl } } = JSON.parse(appOpenResponse.body) + + return { + app_url, + access_token, + access_token_ttl + } +} diff --git a/packages/k6-tests/src/clients/cool/io/index.ts b/packages/k6-tests/src/clients/cool/io/index.ts new file mode 100644 index 0000000..5e7ba7d --- /dev/null +++ b/packages/k6-tests/src/clients/cool/io/index.ts @@ -0,0 +1,2 @@ +export { EngineType, MessageType, SocketType } from '../../onlyoffice/io/io' +export { Session } from '../../onlyoffice/io/session' diff --git a/packages/k6-tests/src/clients/cool/utils.ts b/packages/k6-tests/src/clients/cool/utils.ts new file mode 100644 index 0000000..57d1d4e --- /dev/null +++ b/packages/k6-tests/src/clients/cool/utils.ts @@ -0,0 +1,9 @@ +import { EngineType, SocketType } from './io' + +export const encodeData = (p: { engineType: EngineType, socketType?: SocketType, data?: unknown }) => { + return [p.engineType, p.socketType, JSON.stringify(p.data)] + .filter((v) => { + return v !== undefined + }) + .join('') +} diff --git a/packages/k6-tests/src/clients/cool/worker.ts b/packages/k6-tests/src/clients/cool/worker.ts new file mode 100644 index 0000000..568895a --- /dev/null +++ b/packages/k6-tests/src/clients/cool/worker.ts @@ -0,0 +1,22 @@ +import { EngineType, MessageType, Session, SocketType } from './io' + +export const docsWorker = async (p: { session: Session }): Promise => { + + p.session.subscribe({ + engineType: EngineType.enum.message, + socketType: SocketType.enum.event, + messageType: MessageType.enum.error, + fn: async ({ messageData }) => { + console.error(messageData) + } + }) + + p.session.subscribe({ + engineType: EngineType.enum.message, + socketType: SocketType.enum.event, + messageType: MessageType.enum.drop, + fn: async ({ messageData }) => { + console.error(messageData) + } + }) +} diff --git a/packages/k6-tests/src/values/env.ts b/packages/k6-tests/src/values/env.ts index aca8cd8..542fddc 100644 --- a/packages/k6-tests/src/values/env.ts +++ b/packages/k6-tests/src/values/env.ts @@ -153,6 +153,14 @@ export const envValues = () => { get app_name() { return ENV('ONLY_OFFICE_APP_NAME', 'OnlyOffice') } + }, + cool: { + get wss_url() { + return ENV('COOL_WSS_URL', 'wss://localhost:9980/') + }, + get app_name() { + return ENV('COOL_APP_NAME', 'Collabora') + } } } diff --git a/packages/k6-tests/tests/koko/cool/010-open-change-save/baseline.k6.ts b/packages/k6-tests/tests/koko/cool/010-open-change-save/baseline.k6.ts new file mode 100644 index 0000000..ce0f26c --- /dev/null +++ b/packages/k6-tests/tests/koko/cool/010-open-change-save/baseline.k6.ts @@ -0,0 +1,14 @@ +import { Options } from 'k6/options' + +import { open_change_save_010, open_change_save_010_setup, open_change_save_010_teardown, options as inherited_options } from './simple.k6' + +export const options: Options = { + ...inherited_options, + iterations: 10, + duration: '7d', + teardownTimeout: '1h' +} + +export const setup = open_change_save_010_setup +export default open_change_save_010 +export const teardown = open_change_save_010_teardown diff --git a/packages/k6-tests/tests/koko/cool/010-open-change-save/ramping.k6.ts b/packages/k6-tests/tests/koko/cool/010-open-change-save/ramping.k6.ts new file mode 100644 index 0000000..ee0641b --- /dev/null +++ b/packages/k6-tests/tests/koko/cool/010-open-change-save/ramping.k6.ts @@ -0,0 +1,22 @@ +import { Options } from 'k6/options' +import { omit } from 'lodash' + +import { options as inherited_options } from './baseline.k6' + +export { open_change_save_010, open_change_save_010_setup as setup, open_change_save_010_teardown as teardown } from './simple.k6' + +export const options: Options = { + ...omit(inherited_options, 'iterations', 'duration'), + scenarios: { + open_change_save_010: { + executor: 'ramping-vus', + startVUs: 0, + exec: 'open_change_save_010', + stages: [ + { target: 10, duration: '20s' }, + { target: 10, duration: '30s' }, + { target: 0, duration: '10s' } + ] + } + } +} diff --git a/packages/k6-tests/tests/koko/cool/010-open-change-save/simple.k6.ts b/packages/k6-tests/tests/koko/cool/010-open-change-save/simple.k6.ts new file mode 100644 index 0000000..081a225 --- /dev/null +++ b/packages/k6-tests/tests/koko/cool/010-open-change-save/simple.k6.ts @@ -0,0 +1,133 @@ +import { ENV, queryXml, store } from '@ownclouders/k6-tdk/lib/utils' +import { sleep } from 'k6' +import exec from 'k6/execution' +import { Counter } from 'k6/metrics' +import { Options } from 'k6/options' + +import { Client, obtainDocumentInformation } from '@/clients/cool' +import { userPool } from '@/pools' +import { clientFor } from '@/shortcuts' +import { getTestRoot } from '@/test' +import { getPoolItem } from '@/utils' +import { envValues } from '@/values' + +export interface Environment { + testRoot: string; +} + +// eslint-disable-next-line no-restricted-globals +const docX = open('../data/sample.docx', 'b') + +export const options: Options = { + vus: 1, + iterations: 1, + insecureSkipTLSVerify: true +} + +const settings = { + ...envValues(), + get docx() { + return ENV('DOCX', [settings.seed.resource.root, 'sample.docx'].join('/')) + } +} + +const coolClientErrors = new Counter('cool_client_errors') + +export const open_change_save_010_setup = async (): Promise => { + const adminClient = clientFor({ userLogin: settings.admin.login, userPassword: settings.admin.password }) + const rootInfo = await getTestRoot({ + client: adminClient, + userLogin: settings.admin.login, + platform: settings.platform.type, + resourceName: settings.seed.container.name, + resourceType: settings.seed.container.type, + isOwner: false + }) + + const testRoot = [rootInfo.root, rootInfo.path].join('/') + + adminClient.resource.uploadResource({ + root: testRoot, + resourcePath: settings.docx, + resourceBytes: docX + }) + + return { + testRoot + } +} + +export const open_change_save_010 = async ({ testRoot }: Environment): Promise => { + const user = getPoolItem({ pool: userPool, n: exec.vu.idInTest }) + const userStore = store(user.userLogin) + const documentInformation = await userStore.setOrGet('root', async () => { + const ocisClient = clientFor(user) + + const getResourcePropertiesResponse = await ocisClient.resource.getResourceProperties({ + root: testRoot, + resourcePath: settings.docx + }) + sleep(settings.sleep.after_request) + + const [resourceId] = queryXml("$..['oc:fileid']", getResourcePropertiesResponse.body) + return obtainDocumentInformation({ client: ocisClient, appName: settings.cool.app_name, resourceId }) + }) + + const { app_url } = documentInformation + + const coolClient = new Client({ + ...documentInformation, + url: app_url + }) + + coolClient.onError((err) => { + console.error(err?.error) + coolClientErrors.add(1, { errorType: err?.error || 'unknown' }) + }) + + await coolClient.establishSession() + sleep(settings.sleep.after_request) + + // todo: move into pool + const changes = [ + 'textinput id=0 text=H', + 'textinput id=0 text=e', + 'textinput id=0 text=l', + 'textinput id=0 text=l', + 'textinput id=0 text=o', + 'key type=input char=32 key=0', + 'textinput id=0 text=w', + 'textinput id=0 text=o', + 'textinput id=0 text=r', + 'textinput id=0 text=l', + 'textinput id=0 text=d', + 'key type=input char=33 key=0' + ] + + await coolClient.makeChanges({ changes }) + sleep(settings.sleep.after_request) + + coolClient.disconnect() + sleep(settings.sleep.after_iteration) +} + +export const open_change_save_010_teardown = ({ testRoot }: Environment): void => { + const adminClient = clientFor({ userLogin: settings.admin.login, userPassword: settings.admin.password }) + + const waitForUnlock = () => { + const { body } = adminClient.resource.getResourceProperties({ root: testRoot, resourcePath: settings.docx }) + + if(queryXml("$..['d:activelock']", body).length !== 0){ + sleep(1) + waitForUnlock() + } + } + + waitForUnlock() + + adminClient.resource.deleteResource({ root: testRoot, resourcePath: settings.docx }) +} + +export const setup = open_change_save_010_setup +export default open_change_save_010 +export const teardown = open_change_save_010_teardown