diff --git a/.drone.jsonnet b/.drone.jsonnet index f269537..57e863f 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -1,19 +1,17 @@ local name = 'mattermost'; -local browser = 'firefox'; -local nginx = '1.24.0'; -local postgresql = "15-bullseye"; -local node = "18-bookworm-slim"; -local platforms = { - bookworm: '26.03', - buster: '25.02', -}; -local selenium = '4.35.0-20250828'; +local postgresql = '15-bullseye'; +local platform = '26.08.01'; +local playwright = 'mcr.microsoft.com/playwright:v1.48.2-jammy'; local store_publisher = 'stable-346'; -local mattermost = '11.5.1-syncloud'; +local mattermost = '11.10.0-syncloud'; local python = '3.12-slim-bookworm'; +local golang = '1.25'; +local debian = 'bookworm-slim'; local distro_default = 'bookworm'; local distros = ['bookworm']; -local dind = '20.10.21-dind'; + +local platform_image(distro) = + 'syncloud/platform-' + distro + ':' + platform; local build(arch, test_ui) = [{ kind: 'pipeline', @@ -24,239 +22,148 @@ local build(arch, test_ui) = [{ arch: arch, }, steps: [ - { - name: 'version', - image: 'debian:bookworm-slim', - commands: [ - 'echo $DRONE_BUILD_NUMBER > version', - ], - }, - { - name: 'cli', - image: 'golang:1.25', - commands: [ - 'cd cli', - 'CGO_ENABLED=0 go build -o ../build/snap/meta/hooks/install ./cmd/install', - 'CGO_ENABLED=0 go build -o ../build/snap/meta/hooks/configure ./cmd/configure', - 'CGO_ENABLED=0 go build -o ../build/snap/meta/hooks/pre-refresh ./cmd/pre-refresh', - 'CGO_ENABLED=0 go build -o ../build/snap/meta/hooks/post-refresh ./cmd/post-refresh', - 'CGO_ENABLED=0 go build -o ../build/snap/bin/cli ./cmd/cli', - ], - }, - - { - name: "postgresql", - image: "postgres:" + postgresql, - commands: [ - "./postgresql/build.sh" - ] - - }, + { + name: 'cli', + image: 'golang:' + golang, + commands: [ + './cli/build.sh', + ], + }, + { + name: 'postgresql', + image: 'postgres:' + postgresql, + commands: [ + './postgresql/build.sh', + ], + }, { name: 'mattermost', - image: 'debian:bookworm-slim', + image: 'debian:' + debian, commands: [ './mattermost/download.sh ' + arch + ' ' + mattermost, ], }, - ] + [ - { - name: "postgresql test " + distro, - image: 'syncloud/platform-' + distro + '-' + arch + ':' + platforms[distro], - commands: [ - "./postgresql/test.sh" - ] - } - for distro in distros - ] + [ + ] + [ + { + name: 'postgresql test ' + distro, + image: platform_image(distro), + commands: [ + './postgresql/test.sh', + ], + } + for distro in distros + ] + [ { name: 'mattermost test ' + distro, - image: 'syncloud/platform-' + distro + '-' + arch + ':' + platforms[distro], + image: platform_image(distro), commands: [ './mattermost/test.sh', ], } for distro in distros - ] + [ - + ] + [ { name: 'package', - image: 'debian:bookworm-slim', + image: 'debian:' + debian, commands: [ - 'VERSION=$(cat version)', - './package.sh ' + name + ' $VERSION ', + './package.sh ' + name + ' $DRONE_BUILD_NUMBER', ], }, - ] + [ - { - name: 'test ' + distro, - image: 'python:' + python, - commands: [ - 'cd test', - './deps.sh', - 'py.test -x -s test.py --distro=' + distro + ' --ver=$DRONE_BUILD_NUMBER --app=' + name, - ], - } - for distro in distros - - ] + (if test_ui then [ - { - name: 'selenium', - image: 'selenium/standalone-' + browser + ':' + selenium, - detach: true, - environment: { - SE_NODE_SESSION_TIMEOUT: '999999', - START_XVFB: 'true', - }, - volumes: [{ - name: 'shm', - path: '/dev/shm', - }], - commands: [ - 'cat /etc/hosts', - 'DOMAIN="' + distro_default + '.com"', - 'APP_DOMAIN="' + name + '.' + distro_default + '.com"', - 'getent hosts $APP_DOMAIN | sed "s/$APP_DOMAIN/auth.$DOMAIN/g" | sudo tee -a /etc/hosts', - 'cat /etc/hosts', - '/opt/bin/entry_point.sh', - ], - }, - { - name: 'selenium-video', - image: 'selenium/video:ffmpeg-8.0-20251212', - detach: true, - environment: { - DISPLAY_CONTAINER_NAME: 'selenium', - FILE_NAME: 'video.mkv', - }, - volumes: [ - { - name: 'shm', - path: '/dev/shm', - }, - { - name: 'videos', - path: '/videos', - }, - ], - }, - - { - name: 'test-ui', - image: 'python:' + python, - commands: [ - 'cd test', - './deps.sh', - 'py.test -x -s ui.py --distro=' + distro_default + ' --ver=$DRONE_BUILD_NUMBER --app=' + name + ' --browser=' + browser, - ], - privileged: true, - volumes: [{ - name: 'videos', - path: '/videos', - }], - }, - ] - else []) + - (if arch == 'amd64' then [ - { - name: 'test-upgrade', - image: 'python:' + python, - commands: [ - 'cd test', - './deps.sh', - 'py.test -x -s upgrade.py --distro=' + distro_default + ' --ver=$DRONE_BUILD_NUMBER --app=' + name, - ], - privileged: true, - volumes: [{ - name: 'videos', - path: '/videos', - }], - }, - ] else []) + [ - { + ] + [ + { + name: 'test ' + distro, + image: 'python:' + python, + commands: [ + './ci/test.sh test.py ' + distro + ' ' + name, + ], + } + for distro in distros + ] + (if test_ui then [ + { + name: 'e2e', + image: playwright, + commands: [ + './test/e2e/run.sh e2e specs/01-smoke.spec.ts', + ], + }, + { + name: 'test-upgrade-prev', + image: 'python:' + python, + commands: [ + './ci/test.sh upgrade_prev.py ' + distro_default + ' ' + name, + ], + }, + { + name: 'e2e-before-upgrade', + image: playwright, + commands: [ + './test/e2e/run.sh e2e-before-upgrade specs/02-pre-upgrade.spec.ts', + ], + }, + { + name: 'test-upgrade', + image: 'python:' + python, + commands: [ + './ci/test.sh upgrade.py ' + distro_default + ' ' + name, + ], + }, + { + name: 'e2e-after-upgrade', + image: playwright, + commands: [ + './test/e2e/run.sh e2e-after-upgrade specs/03-post-upgrade.spec.ts', + ], + }, + ] else []) + [ + { name: 'publish', image: 'syncloud/store-publisher:' + store_publisher, environment: { - SYNCLOUD_TOKEN: { from_secret: 'SYNCLOUD_TOKEN' }, + SYNCLOUD_TOKEN: { from_secret: 'SYNCLOUD_TOKEN' }, }, command: ['snap', '-c', '${DRONE_BRANCH}'], when: { - branch: ['master', 'stable'], - event: ['push'], + branch: ['master', 'stable'], + event: ['push'], }, - }, + }, { name: 'artifact', image: 'appleboy/drone-scp:1.6.4', settings: { - host: { - from_secret: 'artifact_host', - }, + host: { from_secret: 'artifact_host' }, username: 'artifact', - key: { - from_secret: 'artifact_key', - }, + key: { from_secret: 'artifact_key' }, timeout: '2m', command_timeout: '2m', target: '/home/artifact/repo/' + name + '/${DRONE_BUILD_NUMBER}-' + arch, - source: 'artifact/*', + source: ['artifact/*'], strip_components: 1, }, when: { status: ['failure', 'success'], - event: ['push'], }, }, ], trigger: { - event: [ - 'push', - 'pull_request', - ], + event: ['push'], }, services: [ { name: name + '.' + distro + '.com', - image: 'syncloud/platform-' + distro + '-' + arch + ':' + platforms[distro], + image: platform_image(distro), privileged: true, + entrypoint: ['/bin/sh', '-c', "mkdir -p /etc/systemd/system/snapd.service.d && printf '[Service]\\nExecStartPost=/bin/sh -c \"/usr/bin/snap set system refresh.hold=2099-01-01T00:00:00Z\"\\n' > /etc/systemd/system/snapd.service.d/disable-refresh.conf && exec /sbin/init"], volumes: [ - { - name: 'dbus', - path: '/var/run/dbus', - }, - { - name: 'dev', - path: '/dev', - }, + { name: 'dbus', path: '/var/run/dbus' }, + { name: 'dev', path: '/dev' }, ], } for distro in distros ], volumes: [ - { - name: 'dbus', - host: { - path: '/var/run/dbus', - }, - }, - { - name: 'dev', - host: { - path: '/dev', - }, - }, - { - name: 'shm', - temp: {}, - }, - { - name: 'dockersock', - temp: {}, - }, - { - name: 'videos', - temp: {}, - }, + { name: 'dbus', host: { path: '/var/run/dbus' } }, + { name: 'dev', host: { path: '/dev' } }, ], }]; diff --git a/.gitignore b/.gitignore index 723ef36..f55f5c6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ -.idea \ No newline at end of file +.idea +venv +node_modules +build +artifact diff --git a/apt.sh b/apt.sh new file mode 100755 index 0000000..0490512 --- /dev/null +++ b/apt.sh @@ -0,0 +1,12 @@ +#!/bin/bash -e + +for i in $(seq 1 10); do + if apt-get update && apt-get install -y "$@"; then + exit 0 + fi + echo "retry apt" + sleep 5 +done + +echo "apt failed" +exit 1 diff --git a/ci/test.sh b/ci/test.sh new file mode 100755 index 0000000..c267df7 --- /dev/null +++ b/ci/test.sh @@ -0,0 +1,11 @@ +#!/bin/bash -ex + +DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) +cd ${DIR}/../test + +SPEC=$1 +DISTRO=$2 +APP=$3 + +./deps.sh +py.test -x -s ${SPEC} --distro=${DISTRO} --ver=${DRONE_BUILD_NUMBER} --app=${APP} diff --git a/cli/build.sh b/cli/build.sh new file mode 100755 index 0000000..4507015 --- /dev/null +++ b/cli/build.sh @@ -0,0 +1,12 @@ +#!/bin/bash -ex + +DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) +cd ${DIR} + +BUILD_DIR=${DIR}/../build/snap + +CGO_ENABLED=0 go build -o ${BUILD_DIR}/meta/hooks/install ./cmd/install +CGO_ENABLED=0 go build -o ${BUILD_DIR}/meta/hooks/configure ./cmd/configure +CGO_ENABLED=0 go build -o ${BUILD_DIR}/meta/hooks/pre-refresh ./cmd/pre-refresh +CGO_ENABLED=0 go build -o ${BUILD_DIR}/meta/hooks/post-refresh ./cmd/post-refresh +CGO_ENABLED=0 go build -o ${BUILD_DIR}/bin/cli ./cmd/cli diff --git a/download-retry.sh b/download-retry.sh new file mode 100755 index 0000000..b04004b --- /dev/null +++ b/download-retry.sh @@ -0,0 +1,15 @@ +#!/bin/bash -e + +URL=$1 +TARGET=$2 + +for i in $(seq 1 10); do + if wget --progress=dot:giga --tries=3 --timeout=60 "${URL}" -O "${TARGET}"; then + exit 0 + fi + echo "retry download ${URL}" + sleep 10 +done + +echo "failed to download ${URL}" +exit 1 diff --git a/mattermost/download.sh b/mattermost/download.sh index 72a48d6..277fd20 100755 --- a/mattermost/download.sh +++ b/mattermost/download.sh @@ -8,13 +8,12 @@ mkdir -p ${BUILD_DIR} ARCH=$1 VERSION=$2 -apt update -apt -y install wget +${DIR}/../apt.sh wget cd ${DIR}/../build mkdir mattermost cd mattermost -wget --progress=dot:giga https://github.com/cyberb/mattermost/releases/download/$VERSION/server-$ARCH-$VERSION.tar.gz -O server.tar.gz +${DIR}/../download-retry.sh https://github.com/cyberb/mattermost/releases/download/$VERSION/server-$ARCH-$VERSION.tar.gz server.tar.gz tar xf server.tar.gz rm -rf server.tar.gz mv * ${BUILD_DIR} @@ -24,7 +23,7 @@ rm -rf mattermost mkdir web cd web -wget --progress=dot:giga https://github.com/cyberb/mattermost/releases/download/$VERSION/web-$VERSION.tar.gz -O web.tar.gz +${DIR}/../download-retry.sh https://github.com/cyberb/mattermost/releases/download/$VERSION/web-$VERSION.tar.gz web.tar.gz tar xf web.tar.gz rm -rf web.tar.gz mv * ${BUILD_DIR} diff --git a/package.sh b/package.sh index 3fa4c77..11ae624 100755 --- a/package.sh +++ b/package.sh @@ -14,12 +14,11 @@ ARCH=$(dpkg --print-architecture) SNAP_DIR=${DIR}/build/snap -apt update -apt -y install squashfs-tools wget +${DIR}/apt.sh squashfs-tools wget cp -r ${DIR}/bin ${SNAP_DIR} cp -r ${DIR}/config ${SNAP_DIR} -cp ${DIR}/snap.yaml ${SNAP_DIR}/meta +cp -r ${DIR}/meta/. ${SNAP_DIR}/meta echo "version: $VERSION" >> ${SNAP_DIR}/meta/snap.yaml echo "architectures:" >> ${SNAP_DIR}/meta/snap.yaml diff --git a/test/conftest.py b/test/conftest.py index af9452e..406a2f2 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -7,8 +7,3 @@ @pytest.fixture(scope="session") def project_dir(): return join(DIR, '..') - - -@pytest.fixture(scope="session") -def selenium_timeout(): - return 60 diff --git a/test/debug.py b/test/debug.py deleted file mode 100644 index fbb1c7e..0000000 --- a/test/debug.py +++ /dev/null @@ -1,38 +0,0 @@ -from os.path import dirname, join - -from selenium import webdriver -from syncloudlib.integration.selenium_wrapper import SeleniumWrapper - -from test.ui import test_publish_video, test_login - -DIR = dirname(__file__) - - -def test_chrome(): - # sudo docker run -d --name chrome --network ip6net -p 4444:4444 -p 5900:5900 -p 7900:7900 --shm-size="2g" selenium/standalone-chrome:4.21.0-20240517 - # firefox http://localhost:7900 - # password: secret - - options = webdriver.ChromeOptions() - options.add_argument('--no-sandbox') - options.add_argument('--disable-dev-shm-usage') - options.set_capability('goog:loggingPrefs', {'performance': 'ALL'}) - options.set_capability('acceptInsecureCerts', True) - driver = webdriver.Remote(options=options) - driver.maximize_window() - selenium = SeleniumWrapper( - driver, - "desktop", - join(DIR, "artifact"), - "example.com", - 100, - "chrome" - ) - - try: - test_login(selenium, "test", "test1234") - test_publish_video(selenium) - finally: - print() - selenium.log() - driver.quit() diff --git a/test/deps.sh b/test/deps.sh index d9175f2..bded41c 100755 --- a/test/deps.sh +++ b/test/deps.sh @@ -1,9 +1,5 @@ #!/bin/bash -e DIR=$( cd "$( dirname "$0" )" && pwd ) -while ! apt-get update; do - sleep 1 - echo "retry" -done -apt-get install -y sshpass openssh-client wget +${DIR}/../apt.sh sshpass openssh-client wget pip install -r requirements.txt diff --git a/test/e2e/globalTeardown.ts b/test/e2e/globalTeardown.ts new file mode 100644 index 0000000..37ec0e1 --- /dev/null +++ b/test/e2e/globalTeardown.ts @@ -0,0 +1,18 @@ +import { ssh, scpFrom } from './helpers/ssh' +import { env } from './helpers/env' +import * as path from 'node:path' +import * as fs from 'node:fs' +import { execSync } from 'node:child_process' + +const TMP_DIR = '/tmp/syncloud/mattermost-ui' + +export default async function () { + const out = path.join(env('PLAYWRIGHT_ARTIFACT_DIR'), 'playwright', env('PLAYWRIGHT_PROJECT')) + fs.mkdirSync(out, { recursive: true }) + + ssh(`mkdir -p ${TMP_DIR}`, { throw: false }) + ssh(`journalctl > ${TMP_DIR}/journalctl.log`, { throw: false }) + ssh(`cat /var/snap/platform/current/config/authelia/config.yml > ${TMP_DIR}/authelia.config.yml.log`, { throw: false }) + scpFrom(`${TMP_DIR}/*`, out, { throw: false }) + try { execSync(`chmod -R a+r ${out}`) } catch {} +} diff --git a/test/e2e/helpers/env.ts b/test/e2e/helpers/env.ts new file mode 100644 index 0000000..cd171a2 --- /dev/null +++ b/test/e2e/helpers/env.ts @@ -0,0 +1,7 @@ +export function env(name: string): string { + const value = process.env[name] + if (!value) { + throw new Error(`${name} is not set`) + } + return value +} diff --git a/test/e2e/helpers/fixtures.ts b/test/e2e/helpers/fixtures.ts new file mode 100644 index 0000000..88987ad --- /dev/null +++ b/test/e2e/helpers/fixtures.ts @@ -0,0 +1,14 @@ +import { test as base } from '@playwright/test' +import { shoot } from './screenshot' + +export const test = base.extend({}) + +test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === testInfo.expectedStatus) { + return + } + console.log(`failure url: ${page.url()}`) + await shoot(page, testInfo, 'failure') +}) + +export { expect } from '@playwright/test' diff --git a/test/e2e/helpers/mattermost.ts b/test/e2e/helpers/mattermost.ts new file mode 100644 index 0000000..8065d56 --- /dev/null +++ b/test/e2e/helpers/mattermost.ts @@ -0,0 +1,104 @@ +import { Locator, Page, expect } from '@playwright/test' + +const ORGANIZATION = 'testorg' + +async function clickIfVisible(locator: Locator, timeout: number): Promise { + try { + await locator.waitFor({ state: 'visible', timeout }) + } catch { + return false + } + await locator.click() + return true +} + +export async function waitLoaded(page: Page) { + await expect(page.locator('#initialPageLoadingScreen')).toBeHidden() +} + +export async function viewInBrowser(page: Page) { + await waitLoaded(page) + await clickIfVisible(page.getByText('View in Browser'), 30_000) + await waitLoaded(page) +} + +export async function loginLdap(page: Page, user: string, password: string) { + const ldap = page.getByText(/AD\/LDAP Credential/) + try { + await ldap.waitFor({ state: 'visible', timeout: 30_000 }) + await ldap.scrollIntoViewIfNeeded() + await ldap.click() + await waitLoaded(page) + } catch { + await waitLoaded(page) + } + + await page.locator('#input_loginId').fill(user) + await page.locator('#input_password-input').fill(password) + await page.locator('[data-testid="saveSetting"]').click() +} + +export async function completeOnboarding(page: Page) { + const organization = page.locator('[data-testid="continue"]') + const composer = page.locator('[data-testid="post_textbox"]') + await expect(organization.or(composer).first()).toBeVisible() + + if (!(await organization.isVisible())) { + return + } + + await page.locator('input.Organization__input').fill(ORGANIZATION) + await organization.click() + + const plugins = page.locator('.Plugins-body') + if (await clickIfVisible(plugins.getByRole('button', { name: 'Continue' }), 60_000)) { + await expect(plugins).toBeHidden() + } + + const invite = page.locator('.InviteMembers__submit') + if (await clickIfVisible(invite.getByRole('button', { name: 'Finish setup' }), 60_000)) { + await expect(invite).toBeHidden() + } +} + +export async function dismissModals(page: Page) { + const modal = page.locator('#genericModal') + for (let i = 0; i < 3; i++) { + if (!(await clickIfVisible(modal.locator('button.close'), 5_000))) { + break + } + await expect(modal).toBeHidden() + } + await clickIfVisible(page.locator('[data-cy="onboarding-task-list-overlay"]'), 5_000) +} + +export async function waitChat(page: Page) { + const composer = page.locator('[data-testid="post_textbox"]') + try { + await expect(composer).toBeVisible({ timeout: 120_000 }) + } catch { + await page.goto('/') + await expect(composer).toBeVisible({ timeout: 120_000 }) + } + await dismissModals(page) +} + +export async function postMessage(page: Page, text: string) { + await dismissModals(page) + await page.locator('[data-testid="post_textbox"]').fill(text) + await dismissModals(page) + await page.locator('[data-testid="SendMessageButton"]').click() +} + +export async function expectMessage(page: Page, text: string) { + await dismissModals(page) + await expect(page.locator('.post-message__text', { hasText: text }).first()).toBeVisible() +} + +export async function login(page: Page, user: string, password: string) { + await page.goto('/') + await viewInBrowser(page) + await loginLdap(page, user, password) + await completeOnboarding(page) + await waitChat(page) +} diff --git a/test/e2e/helpers/screenshot.ts b/test/e2e/helpers/screenshot.ts new file mode 100644 index 0000000..b7360b9 --- /dev/null +++ b/test/e2e/helpers/screenshot.ts @@ -0,0 +1,12 @@ +import { Page, TestInfo } from '@playwright/test' +import * as path from 'node:path' +import * as fs from 'node:fs' +import { env } from './env' + +export async function shoot(page: Page, testInfo: TestInfo, name: string) { + const view = testInfo.project.name + const dir = path.join(env('PLAYWRIGHT_ARTIFACT_DIR'), 'playwright', view, 'screenshot') + fs.mkdirSync(dir, { recursive: true }) + await page.screenshot({ path: path.join(dir, `${name}-${view}.png`), fullPage: false }) + fs.writeFileSync(path.join(dir, `${name}-${view}.html`), await page.content()) +} diff --git a/test/e2e/helpers/ssh.ts b/test/e2e/helpers/ssh.ts new file mode 100644 index 0000000..1693903 --- /dev/null +++ b/test/e2e/helpers/ssh.ts @@ -0,0 +1,31 @@ +import { execFileSync } from 'node:child_process' +import { env } from './env' + +const baseArgs = [ + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '-o', 'LogLevel=ERROR', +] + +export function ssh(cmd: string, opts: { throw?: boolean } = {}): string { + const args = ['-p', env('PLAYWRIGHT_SSH_PASSWORD'), 'ssh', ...baseArgs, + `${env('PLAYWRIGHT_SSH_USER')}@${env('PLAYWRIGHT_DEVICE_HOST')}`, cmd] + try { + return execFileSync('sshpass', args, { encoding: 'utf8', timeout: 120_000 }) + } catch (e: any) { + if (opts.throw === false) { + return (e.stdout?.toString() ?? '') + (e.stderr?.toString() ?? '') + } + throw e + } +} + +export function scpFrom(remote: string, local: string, opts: { throw?: boolean } = {}): void { + const args = ['-p', env('PLAYWRIGHT_SSH_PASSWORD'), 'scp', ...baseArgs, '-r', + `${env('PLAYWRIGHT_SSH_USER')}@${env('PLAYWRIGHT_DEVICE_HOST')}:${remote}`, local] + try { + execFileSync('sshpass', args, { encoding: 'utf8', timeout: 120_000 }) + } catch (e) { + if (opts.throw !== false) throw e + } +} diff --git a/test/e2e/package-lock.json b/test/e2e/package-lock.json new file mode 100644 index 0000000..c712926 --- /dev/null +++ b/test/e2e/package-lock.json @@ -0,0 +1,111 @@ +{ + "name": "mattermost-e2e", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mattermost-e2e", + "version": "0.0.0", + "devDependencies": { + "@playwright/test": "1.48.2", + "@types/node": "22.9.0", + "typescript": "5.6.3" + } + }, + "node_modules/@playwright/test": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.2.tgz", + "integrity": "sha512-54w1xCWfXuax7dz4W2M9uw0gDyh+ti/0K/MxcCUxChFh37kkdxPdfZDw5QBbuPUJHr1CiHJ1hXgSs+GgeQc5Zw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.48.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", + "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.2.tgz", + "integrity": "sha512-NjYvYgp4BPmiwfe31j4gHLa3J7bD2WiBz8Lk2RoSsmX38SVIARZ18VYjxLjAcDsAhA+F4iSEXTSGgjua0rrlgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.48.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.2.tgz", + "integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/test/e2e/package.json b/test/e2e/package.json new file mode 100644 index 0000000..a4b20bb --- /dev/null +++ b/test/e2e/package.json @@ -0,0 +1,14 @@ +{ + "name": "mattermost-e2e", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.48.2", + "@types/node": "22.9.0", + "typescript": "5.6.3" + } +} diff --git a/test/e2e/playwright.config.ts b/test/e2e/playwright.config.ts new file mode 100644 index 0000000..5b630d6 --- /dev/null +++ b/test/e2e/playwright.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, devices } from '@playwright/test' +import { env } from './helpers/env' + +const fullDomain = env('PLAYWRIGHT_FULL_DOMAIN') +const appDomain = env('PLAYWRIGHT_APP_DOMAIN') +const artifactDir = env('PLAYWRIGHT_ARTIFACT_DIR') + +export default defineConfig({ + testDir: './specs', + fullyParallel: false, + workers: 1, + retries: 0, + maxFailures: 1, + reporter: [['list']], + outputDir: `${artifactDir}/playwright/test-results`, + globalTeardown: './globalTeardown.ts', + timeout: 420_000, + expect: { timeout: 60_000 }, + use: { + baseURL: `https://${appDomain}`, + ignoreHTTPSErrors: true, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'on', + }, + projects: [ + { + name: 'desktop', + use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 960 } }, + }, + ], + metadata: { + appDomain, + fullDomain, + artifactDir, + }, +}) diff --git a/test/e2e/run.sh b/test/e2e/run.sh new file mode 100755 index 0000000..de17604 --- /dev/null +++ b/test/e2e/run.sh @@ -0,0 +1,22 @@ +#!/bin/bash -e + +DIR=$(cd "$(dirname "$0")" && pwd) +cd "$DIR" + +ARTIFACT_SUBDIR=$1 +SPEC=$2 + +export PLAYWRIGHT_FULL_DOMAIN=bookworm.com +export PLAYWRIGHT_APP_DOMAIN=mattermost.bookworm.com +export PLAYWRIGHT_DEVICE_HOST=mattermost.bookworm.com +export PLAYWRIGHT_DEVICE_USER=user +export PLAYWRIGHT_DEVICE_PASSWORD=Password1 +export PLAYWRIGHT_SSH_USER=root +export PLAYWRIGHT_SSH_PASSWORD=Password1 +export PLAYWRIGHT_PROJECT=desktop +export PLAYWRIGHT_ARTIFACT_DIR=/drone/src/artifact/${ARTIFACT_SUBDIR} + +${DIR}/../../apt.sh sshpass openssh-client curl +${DIR}/wait-app.sh ${PLAYWRIGHT_APP_DOMAIN} +npm ci --no-audit --no-fund +npx playwright test --project=${PLAYWRIGHT_PROJECT} "$SPEC" diff --git a/test/e2e/specs/01-smoke.spec.ts b/test/e2e/specs/01-smoke.spec.ts new file mode 100644 index 0000000..e3ec54e --- /dev/null +++ b/test/e2e/specs/01-smoke.spec.ts @@ -0,0 +1,19 @@ +import { test } from '../helpers/fixtures' +import { shoot } from '../helpers/screenshot' +import { env } from '../helpers/env' +import { login, postMessage, expectMessage } from '../helpers/mattermost' + +const user = env('PLAYWRIGHT_DEVICE_USER') +const password = env('PLAYWRIGHT_DEVICE_PASSWORD') +const message = 'test message' + +test.describe('mattermost smoke', () => { + test('login, post and read a message', async ({ page }, testInfo) => { + await login(page, user, password) + await shoot(page, testInfo, 'chat') + await postMessage(page, message) + await shoot(page, testInfo, 'post-message') + await expectMessage(page, message) + await shoot(page, testInfo, 'check-message') + }) +}) diff --git a/test/e2e/specs/02-pre-upgrade.spec.ts b/test/e2e/specs/02-pre-upgrade.spec.ts new file mode 100644 index 0000000..8ceb235 --- /dev/null +++ b/test/e2e/specs/02-pre-upgrade.spec.ts @@ -0,0 +1,18 @@ +import { test } from '../helpers/fixtures' +import { shoot } from '../helpers/screenshot' +import { env } from '../helpers/env' +import { login, postMessage, expectMessage } from '../helpers/mattermost' + +const user = env('PLAYWRIGHT_DEVICE_USER') +const password = env('PLAYWRIGHT_DEVICE_PASSWORD') +const message = 'pre upgrade message' + +test.describe('mattermost pre-upgrade', () => { + test('seed a message on the released version', async ({ page }, testInfo) => { + await login(page, user, password) + await shoot(page, testInfo, 'pre-upgrade-chat') + await postMessage(page, message) + await expectMessage(page, message) + await shoot(page, testInfo, 'pre-upgrade-message') + }) +}) diff --git a/test/e2e/specs/03-post-upgrade.spec.ts b/test/e2e/specs/03-post-upgrade.spec.ts new file mode 100644 index 0000000..4a99a7c --- /dev/null +++ b/test/e2e/specs/03-post-upgrade.spec.ts @@ -0,0 +1,22 @@ +import { test } from '../helpers/fixtures' +import { shoot } from '../helpers/screenshot' +import { env } from '../helpers/env' +import { login, postMessage, expectMessage } from '../helpers/mattermost' + +const user = env('PLAYWRIGHT_DEVICE_USER') +const password = env('PLAYWRIGHT_DEVICE_PASSWORD') +const preMessage = 'pre upgrade message' +const postMessageText = 'post upgrade message' + +test.describe('mattermost post-upgrade', () => { + test('pre-upgrade message survives and posting still works', async ({ page }, testInfo) => { + await login(page, user, password) + await shoot(page, testInfo, 'post-upgrade-chat') + await expectMessage(page, preMessage) + await shoot(page, testInfo, 'pre-upgrade-message-verified') + await postMessage(page, postMessageText) + await expectMessage(page, postMessageText) + await expectMessage(page, preMessage) + await shoot(page, testInfo, 'post-upgrade-message') + }) +}) diff --git a/test/e2e/tsconfig.json b/test/e2e/tsconfig.json new file mode 100644 index 0000000..5ca48bf --- /dev/null +++ b/test/e2e/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"] +} diff --git a/test/e2e/wait-app.sh b/test/e2e/wait-app.sh new file mode 100755 index 0000000..4f02bf9 --- /dev/null +++ b/test/e2e/wait-app.sh @@ -0,0 +1,16 @@ +#!/bin/bash -e + +APP_DOMAIN=$1 + +for i in $(seq 1 120); do + CODE=$(curl -sk -o /dev/null -w '%{http_code}' "https://${APP_DOMAIN}" || true) + if [ "$CODE" = "200" ]; then + echo "${APP_DOMAIN} is ready" + exit 0 + fi + echo "waiting for ${APP_DOMAIN}, got ${CODE}" + sleep 5 +done + +echo "${APP_DOMAIN} did not become ready" +exit 1 diff --git a/test/lib.py b/test/lib.py deleted file mode 100644 index b40243a..0000000 --- a/test/lib.py +++ /dev/null @@ -1,120 +0,0 @@ -import time -from os.path import dirname, join -from subprocess import check_output - -import pytest -import requests -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from syncloudlib.integration.hosts import add_host_alias - - -def wait_loaded(selenium): - selenium.invisible_by(By.ID, "initialPageLoadingScreen") - - -def login_prev(mode, selenium, device_user, device_password): - selenium.screenshot(mode+'-before-view-in-browser') - wait_loaded(selenium) - selenium.find_by(By.XPATH, "//span[contains(.,'View in Browser')]").click() - selenium.invisible_by(By.XPATH, "//span[contains(.,'View in Browser')]") - wait_loaded(selenium) - selenium.screenshot(mode+'-ldap') - selenium.click_by(By.XPATH, "//span[contains(.,'LDAP Credential')]") - wait_loaded(selenium) - selenium.find_by(By.ID, "input_loginId").send_keys(device_user) - password = selenium.find_by(By.ID, "input_password-input") - password.send_keys(device_password) - selenium.screenshot(mode+'-login') - selenium.find_by(By.XPATH, "//span[contains(.,'Log in')]").click() - selenium.find_by(By.XPATH, "//input[@placeholder='Organization name']").send_keys("testorg") - selenium.screenshot(mode+'-org') - selenium.click_by(By.XPATH, "//span[.='Continue']") - - selenium.find_by(By.XPATH, "//span[.='What tools do you use?']") - selenium.find_by(By.XPATH, "//span[.='GitHub']") - selenium.find_by(By.XPATH, "//span[.='GitLab']") - selenium.find_by(By.XPATH, "//span[.='Skip']") - selenium.screenshot(mode+'-tools') - selenium.click_by(By.XPATH, '//span[.="What tools do you use?"]/../..//span[.="Continue"]') - selenium.invisible_by(By.XPATH, "//span[.='What tools do you use?']") - # selenium.click_by(By.XPATH, "//span[.='Continue']") - - selenium.find_by(By.XPATH, "//span[.='Invite your team members']") - selenium.screenshot(mode+'-invite') - selenium.click_by(By.XPATH, "//span[.='Finish setup']") - selenium.invisible_by(By.XPATH, "//span[.='Invite your team members']") - - selenium.screenshot(mode+'-after-setup') - # wait for chat to load - selenium.find_by(By.XPATH, "//textarea[@id='post_textbox']") - selenium.screenshot(mode+'-chat-loaded') - dismiss_modals(selenium) - selenium.screenshot(mode+'-chat') - - -def login_next(mode, selenium, device_user, device_password): - selenium.screenshot(mode+'-before-view-in-browser') - wait_loaded(selenium) - selenium.find_by(By.XPATH, "//span[contains(.,'View in Browser')]").click() - selenium.invisible_by(By.XPATH, "//span[contains(.,'View in Browser')]") - wait_loaded(selenium) - selenium.screenshot(mode+'-after-view-in-browser') - elem = selenium.find_by(By.XPATH, "//span[contains(.,'AD/LDAP Credentials')]") - selenium.driver.execute_script("arguments[0].scrollIntoView();", elem) - selenium.screenshot(mode+'-ldap') - elem.click() - wait_loaded(selenium) - selenium.find_by(By.ID, "input_loginId").send_keys(device_user) - password = selenium.find_by(By.ID, "input_password-input") - password.send_keys(device_password) - selenium.screenshot(mode+'-login') - selenium.find_by(By.XPATH, "//span[contains(.,'Log in')]").click() - selenium.find_by(By.XPATH, "//input[@placeholder='Organization name']").send_keys("testorg") - selenium.screenshot(mode+'-org') - selenium.click_by(By.XPATH, "//span[.='Continue']") - - selenium.find_by(By.XPATH, "//span[.='What tools do you use?']") - selenium.screenshot(mode+'-tools') - selenium.click_by(By.XPATH, '//span[.="What tools do you use?"]/../..//span[.="Continue"]') - selenium.invisible_by(By.XPATH, "//span[.='What tools do you use?']") - - selenium.find_by(By.XPATH, "//span[.='Invite your team members']") - selenium.screenshot(mode+'-invite') - selenium.click_by(By.XPATH, "//span[.='Finish setup']") - selenium.invisible_by(By.XPATH, "//span[.='Invite your team members']") - - selenium.screenshot(mode+'-after-setup') - # wait for chat to load - selenium.find_by(By.XPATH, "//textarea[@id='post_textbox']") - selenium.screenshot(mode+'-chat-loaded') - dismiss_modals(selenium) - selenium.screenshot(mode+'-chat') - - -def dismiss_modals(selenium): - import time - time.sleep(2) - # dismiss "Experience a better way to communicate in threads" modal - for xpath in [ - "//button[@class='close']", - "//span[contains(.,'No thanks')]", - ]: - try: - elem = selenium.driver.find_element(By.XPATH, xpath) - selenium.driver.execute_script("arguments[0].click();", elem) - time.sleep(1) - except Exception: - pass - - -def post_message(mode, selenium): - dismiss_modals(selenium) - selenium.clickable_by(By.XPATH, "//textarea[@id='post_textbox']").send_keys("test message") - selenium.click_by(By.XPATH, "//button[@data-testid='SendMessageButton']") - selenium.screenshot(mode+'-post-message') - -def check_message(mode, selenium): - dismiss_modals(selenium) - selenium.find_by(By.XPATH, "//div[@class='post-message__text' and .='test message']") - selenium.screenshot(mode+'-check-message') diff --git a/test/requirements.txt b/test/requirements.txt index cef7f9a..8585725 100644 --- a/test/requirements.txt +++ b/test/requirements.txt @@ -1,5 +1,4 @@ pytest==8.4.1 -selenium==4.35.0 -syncloud-lib==363 +syncloud-lib==366 pytest-retry==1.6.3 retrying==1.4.2 diff --git a/test/ui.py b/test/ui.py deleted file mode 100644 index 1320831..0000000 --- a/test/ui.py +++ /dev/null @@ -1,53 +0,0 @@ -import time -from os.path import dirname, join -from subprocess import check_output - -import pytest -import requests -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from syncloudlib.integration.hosts import add_host_alias -from test import lib - -DIR = dirname(__file__) -TMP_DIR = '/tmp/syncloud/ui' - - -@pytest.fixture(scope="session") -def module_setup(request, device, artifact_dir, ui_mode, driver, selenium): - def teardown(): - device.activated() - device.run_ssh('mkdir -p {0}'.format(TMP_DIR), throw=False) - device.run_ssh('journalctl > {0}/journalctl.ui.{1}.log'.format(TMP_DIR, ui_mode), throw=False) - device.run_ssh('cat /var/snap/platform/current/config/authelia/config.yml > {0}/authelia.config.ui.log'.format(TMP_DIR), throw=False) - device.scp_from_device('{0}/*'.format(TMP_DIR), join(artifact_dir, 'log')) - check_output('cp /videos/* {0}'.format(artifact_dir), shell=True) - check_output('chmod -R a+r {0}'.format(artifact_dir), shell=True) - selenium.log() - - request.addfinalizer(teardown) - - -def test_start(module_setup, app, domain, device_host): - add_host_alias(app, device_host, domain) - - -@pytest.mark.flaky(retries=20, delay=5) -def test_visible_through_platform(app_domain): - response = requests.get('https://{0}'.format(app_domain), verify=False) - assert response.status_code == 200, response.text - - -def test_login(selenium, device_user, device_password): - selenium.open_app() - lib.login_next("install", selenium, device_user, device_password) - - - -def test_post_message(selenium): - lib.post_message("install", selenium) - - -def test_check_message(selenium): - lib.check_message("install", selenium) - diff --git a/test/upgrade.py b/test/upgrade.py index c73c13a..5c7e3a4 100644 --- a/test/upgrade.py +++ b/test/upgrade.py @@ -1,10 +1,9 @@ import pytest -from subprocess import check_output, run +import requests +from subprocess import check_output from syncloudlib.integration.hosts import add_host_alias from syncloudlib.integration.installer import local_install from syncloudlib.http import wait_for_rest -import requests -from test import lib TMP_DIR = '/tmp/syncloud' @@ -12,33 +11,18 @@ @pytest.fixture(scope="session") def module_setup(request, device, artifact_dir): def module_teardown(): + device.run_ssh('mkdir -p {0}'.format(TMP_DIR), throw=False) device.run_ssh('journalctl > {0}/refresh.journalctl.log'.format(TMP_DIR), throw=False) - device.scp_from_device('{0}/*'.format(TMP_DIR), artifact_dir) - run('cp /videos/* {0}'.format(artifact_dir), shell=True) + device.scp_from_device('{0}/*'.format(TMP_DIR), artifact_dir, throw=False) check_output('chmod -R a+r {0}'.format(artifact_dir), shell=True) request.addfinalizer(module_teardown) -def test_start(module_setup, app, device_host, domain, device): +def test_start(module_setup, app, device_host, domain): add_host_alias(app, device_host, domain) - device.activated() - device.run_ssh('rm -rf {0}'.format(TMP_DIR), throw=False) - device.run_ssh('mkdir {0}'.format(TMP_DIR), throw=False) - -def test_upgrade(selenium, device, device_user, device_password, device_host, app_archive_path, app_domain, app_dir): - device.run_ssh('snap remove mattermost') - device.run_ssh('snap install mattermost', retries=10) - wait_for_rest(requests.session(), "https://{0}".format(app_domain), 200, 100) - - selenium.open_app() - lib.login_prev("upgrade", selenium, device_user, device_password) - lib.post_message("upgrade", selenium) - lib.check_message("upgrade", selenium) +def test_upgrade(device_host, device_password, app_archive_path, app_domain): local_install(device_host, device_password, app_archive_path) wait_for_rest(requests.session(), "https://{0}".format(app_domain), 200, 100) - selenium.open_app() - lib.check_message("upgrade", selenium) - diff --git a/test/upgrade_prev.py b/test/upgrade_prev.py new file mode 100644 index 0000000..1ceb9f0 --- /dev/null +++ b/test/upgrade_prev.py @@ -0,0 +1,30 @@ +import pytest +import requests +from subprocess import check_output +from syncloudlib.integration.hosts import add_host_alias +from syncloudlib.http import wait_for_rest + +TMP_DIR = '/tmp/syncloud' + + +@pytest.fixture(scope="session") +def module_setup(request, device, artifact_dir): + def module_teardown(): + device.run_ssh('mkdir -p {0}'.format(TMP_DIR), throw=False) + device.run_ssh('journalctl > {0}/upgrade-prev.journalctl.log'.format(TMP_DIR), throw=False) + device.scp_from_device('{0}/*'.format(TMP_DIR), artifact_dir, throw=False) + check_output('chmod -R a+r {0}'.format(artifact_dir), shell=True) + + request.addfinalizer(module_teardown) + + +def test_start(module_setup, app, device_host, domain, device): + add_host_alias(app, device_host, domain) + device.activated() + device.run_ssh('mkdir -p {0}'.format(TMP_DIR), throw=False) + + +def test_install_prev(device, app, app_domain): + device.run_ssh('snap remove {0}'.format(app), throw=False) + device.run_ssh('snap install {0}'.format(app), retries=10) + wait_for_rest(requests.session(), "https://{0}".format(app_domain), 200, 100)