diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..3de57a360 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules/ +dist/ +logs/ +.DS_Store +.git/ +config/local.json +pleroma-backend/ +test/e2e/reports/ +test/e2e-playwright/test-results/ +test/e2e-playwright/playwright-report/ +__screenshots__/ + diff --git a/.gitignore b/.gitignore index 01ffda9a8..c4a96ee1e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,11 @@ dist/ npm-debug.log test/unit/coverage test/e2e/reports +test/e2e-playwright/test-results +test/e2e-playwright/playwright-report selenium-debug.log .idea/ +.gitlab-ci-local/ config/local.json src/assets/emoji.json logs/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b711c7fc9..5e1dfdb25 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -71,6 +71,135 @@ test: - test/**/__screenshots__ when: on_failure +e2e-pleroma: + stage: test + image: mcr.microsoft.com/playwright:v1.55.0-jammy + services: + - name: postgres:15-alpine + alias: db + - name: $PLEROMA_IMAGE + alias: pleroma + entrypoint: ["/bin/ash", "-c"] + command: + - | + set -eu + + SEED_SENTINEL_PATH=/var/lib/pleroma/.e2e_seeded + CONFIG_OVERRIDE_PATH=/var/lib/pleroma/config.exs + + echo '-- Waiting for database...' + while ! pg_isready -U ${DB_USER:-pleroma} -d postgres://${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-pleroma} -t 1; do + sleep 1s + done + + echo '-- Writing E2E config overrides...' + cat > $CONFIG_OVERRIDE_PATH </dev/null; then + kill -TERM $PLEROMA_PID + wait $PLEROMA_PID || true + fi + } + + trap cleanup INT TERM + + echo '-- Waiting for API...' + api_ok=false + for _i in $(seq 1 120); do + if wget -qO- http://127.0.0.1:4000/api/v1/instance >/dev/null 2>&1; then + api_ok=true + break + fi + sleep 1s + done + + if [ $api_ok != true ]; then + echo 'Timed out waiting for Pleroma API to become available' + exit 1 + fi + + if [ ! -f $SEED_SENTINEL_PATH ]; then + if [ -n ${E2E_ADMIN_USERNAME:-} ] && [ -n ${E2E_ADMIN_PASSWORD:-} ] && [ -n ${E2E_ADMIN_EMAIL:-} ]; then + echo '-- Seeding admin user' $E2E_ADMIN_USERNAME '...' + if ! /opt/pleroma/bin/pleroma_ctl user new $E2E_ADMIN_USERNAME $E2E_ADMIN_EMAIL --admin --password $E2E_ADMIN_PASSWORD -y; then + echo '-- User already exists or creation failed, ensuring admin + confirmed...' + /opt/pleroma/bin/pleroma_ctl user set $E2E_ADMIN_USERNAME --admin --confirmed + fi + else + echo '-- Skipping admin seeding (missing E2E_ADMIN_* env)' + fi + + touch $SEED_SENTINEL_PATH + fi + + wait $PLEROMA_PID + tags: + - amd64 + - himem + variables: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" + FF_NETWORK_PER_BUILD: "true" + PLEROMA_IMAGE: git.pleroma.social:5050/pleroma/pleroma:stable + POSTGRES_USER: pleroma + POSTGRES_PASSWORD: pleroma + POSTGRES_DB: pleroma + DB_USER: pleroma + DB_PASS: pleroma + DB_NAME: pleroma + DB_HOST: db + DB_PORT: 5432 + DOMAIN: localhost + INSTANCE_NAME: Pleroma E2E + E2E_ADMIN_USERNAME: admin + E2E_ADMIN_PASSWORD: adminadmin + E2E_ADMIN_EMAIL: admin@example.com + ADMIN_EMAIL: $E2E_ADMIN_EMAIL + NOTIFY_EMAIL: $E2E_ADMIN_EMAIL + VITE_PROXY_TARGET: http://pleroma:4000 + VITE_PROXY_ORIGIN: http://localhost:4000 + E2E_BASE_URL: http://localhost:8080 + script: + - npm install -g yarn@1.22.22 + - yarn --frozen-lockfile + - | + echo "-- Waiting for Pleroma API..." + api_ok="false" + for _i in $(seq 1 120); do + if wget -qO- http://pleroma:4000/api/v1/instance >/dev/null 2>&1; then + api_ok="true" + break + fi + sleep 1s + done + if [ "$api_ok" != "true" ]; then + echo "Timed out waiting for Pleroma API to become available" + exit 1 + fi + - yarn e2e:pw + artifacts: + when: on_failure + paths: + - test/e2e-playwright/test-results + - test/e2e-playwright/playwright-report + build: stage: build tags: diff --git a/changelog.d/e2e-tests.add b/changelog.d/e2e-tests.add new file mode 100644 index 000000000..ba62b25ac --- /dev/null +++ b/changelog.d/e2e-tests.add @@ -0,0 +1 @@ +Add playwright E2E-tests with an optional docker-based backend diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 000000000..75a4979a1 --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,57 @@ +services: + db: + image: postgres:15-alpine + environment: + POSTGRES_USER: pleroma + POSTGRES_PASSWORD: pleroma + POSTGRES_DB: pleroma + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pleroma -d pleroma"] + interval: 2s + timeout: 2s + retries: 30 + + pleroma: + image: ${PLEROMA_IMAGE:-git.pleroma.social:5050/pleroma/pleroma:stable} + environment: + DB_USER: pleroma + DB_PASS: pleroma + DB_NAME: pleroma + DB_HOST: db + DB_PORT: 5432 + DOMAIN: localhost + INSTANCE_NAME: Pleroma E2E + ADMIN_EMAIL: ${E2E_ADMIN_EMAIL:-admin@example.com} + NOTIFY_EMAIL: ${E2E_ADMIN_EMAIL:-admin@example.com} + E2E_ADMIN_USERNAME: ${E2E_ADMIN_USERNAME:-admin} + E2E_ADMIN_PASSWORD: ${E2E_ADMIN_PASSWORD:-adminadmin} + E2E_ADMIN_EMAIL: ${E2E_ADMIN_EMAIL:-admin@example.com} + depends_on: + db: + condition: service_healthy + volumes: + - ./docker/pleroma/entrypoint.e2e.sh:/opt/pleroma/entrypoint.e2e.sh:ro + entrypoint: ["/bin/ash", "/opt/pleroma/entrypoint.e2e.sh"] + healthcheck: + # NOTE: "localhost" may resolve to ::1 in some images (IPv6) while Pleroma only + # listens on IPv4 in this container. Use 127.0.0.1 to avoid false negatives. + test: ["CMD-SHELL", "test -f /var/lib/pleroma/.e2e_seeded && wget -qO- http://127.0.0.1:4000/api/v1/instance >/dev/null || exit 1"] + interval: 5s + timeout: 3s + retries: 60 + + e2e: + build: + context: . + dockerfile: docker/e2e/Dockerfile.e2e + depends_on: + pleroma: + condition: service_healthy + environment: + CI: "1" + VITE_PROXY_TARGET: http://pleroma:4000 + VITE_PROXY_ORIGIN: http://localhost:4000 + E2E_BASE_URL: http://localhost:8080 + E2E_ADMIN_USERNAME: ${E2E_ADMIN_USERNAME:-admin} + E2E_ADMIN_PASSWORD: ${E2E_ADMIN_PASSWORD:-adminadmin} + command: ["yarn", "e2e:pw"] diff --git a/docker/e2e/Dockerfile.e2e b/docker/e2e/Dockerfile.e2e new file mode 100644 index 000000000..ec780e894 --- /dev/null +++ b/docker/e2e/Dockerfile.e2e @@ -0,0 +1,17 @@ +FROM mcr.microsoft.com/playwright:v1.55.0-jammy + +WORKDIR /app + +ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 + +RUN npm install -g yarn@1.22.22 + +COPY package.json yarn.lock ./ +RUN yarn --frozen-lockfile + +COPY . . + +ENV CI=1 + +CMD ["yarn", "e2e:pw"] + diff --git a/docker/pleroma/entrypoint.e2e.sh b/docker/pleroma/entrypoint.e2e.sh new file mode 100644 index 000000000..96920eeae --- /dev/null +++ b/docker/pleroma/entrypoint.e2e.sh @@ -0,0 +1,71 @@ +#!/bin/ash + +set -eu + +SEED_SENTINEL_PATH="/var/lib/pleroma/.e2e_seeded" +CONFIG_OVERRIDE_PATH="/var/lib/pleroma/config.exs" + +echo "-- Waiting for database..." +while ! pg_isready -U "${DB_USER:-pleroma}" -d "postgres://${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-pleroma}" -t 1; do + sleep 1s +done + +echo "-- Writing E2E config overrides..." +cat > "$CONFIG_OVERRIDE_PATH" <<'EOF' +import Config + +config :pleroma, Pleroma.Captcha, + enabled: false + +config :pleroma, :instance, + registrations_open: true, + account_activation_required: false, + approval_required: false +EOF + +echo "-- Running migrations..." +/opt/pleroma/bin/pleroma_ctl migrate + +echo "-- Starting!" +/opt/pleroma/bin/pleroma start & +PLEROMA_PID="$!" + +cleanup() { + if [ -n "${PLEROMA_PID:-}" ] && kill -0 "$PLEROMA_PID" 2>/dev/null; then + kill -TERM "$PLEROMA_PID" + wait "$PLEROMA_PID" || true + fi +} + +trap cleanup INT TERM + +echo "-- Waiting for API..." +api_ok="false" +for _i in $(seq 1 120); do + if wget -qO- http://127.0.0.1:4000/api/v1/instance >/dev/null 2>&1; then + api_ok="true" + break + fi + sleep 1s +done + +if [ "$api_ok" != "true" ]; then + echo "Timed out waiting for Pleroma API to become available" + exit 1 +fi + +if [ ! -f "$SEED_SENTINEL_PATH" ]; then + if [ -n "${E2E_ADMIN_USERNAME:-}" ] && [ -n "${E2E_ADMIN_PASSWORD:-}" ] && [ -n "${E2E_ADMIN_EMAIL:-}" ]; then + echo "-- Seeding admin user (${E2E_ADMIN_USERNAME})..." + if ! /opt/pleroma/bin/pleroma_ctl user new "$E2E_ADMIN_USERNAME" "$E2E_ADMIN_EMAIL" --admin --password "$E2E_ADMIN_PASSWORD" -y; then + echo "-- User already exists (or creation failed), ensuring admin + confirmed..." + /opt/pleroma/bin/pleroma_ctl user set "$E2E_ADMIN_USERNAME" --admin --confirmed + fi + else + echo "-- Skipping admin seeding (missing E2E_ADMIN_* env)" + fi + + touch "$SEED_SENTINEL_PATH" +fi + +wait "$PLEROMA_PID" diff --git a/package.json b/package.json index 33a1d719f..b00ed545a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "unit": "node build/update-emoji.js && vitest --run", "unit-ci": "node build/update-emoji.js && vitest --run --browser.headless", "unit:watch": "node build/update-emoji.js && vitest", - "e2e": "node test/e2e/runner.js", + "e2e:pw": "playwright test --config test/e2e-playwright/playwright.config.mjs", + "e2e": "sh ./tools/e2e/run.sh", "test": "yarn run unit && yarn run e2e", "ci-biome": "yarn exec biome check", "ci-eslint": "yarn exec eslint", diff --git a/src/components/settings_modal/helpers/rate_setting.vue b/src/components/settings_modal/helpers/rate_setting.vue index f6216223c..49e09e046 100644 --- a/src/components/settings_modal/helpers/rate_setting.vue +++ b/src/components/settings_modal/helpers/rate_setting.vue @@ -30,60 +30,61 @@

- - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + +
  - {{ $t('admin_dash.rate_limit.period') }} - - {{ $t('admin_dash.rate_limit.amount') }} -
- {{ $t('admin_dash.rate_limit.unauthenticated') }} - - {{ $t('admin_dash.rate_limit.rate_limit') }} - - - - -
- {{ $t('admin_dash.rate_limit.authenticated') }} - - - - -
  + {{ $t('admin_dash.rate_limit.period') }} + + {{ $t('admin_dash.rate_limit.amount') }} +
+ {{ isSeparate ? $t('admin_dash.rate_limit.unauthenticated') : $t('admin_dash.rate_limit.rate_limit') }} + + + + +
+ {{ $t('admin_dash.rate_limit.authenticated') }} + + + + +
{ + await page.goto('/login') + + const loginForm = page.locator('#main-scroller form.login-form') + await loginForm.locator('#username').fill(adminUsername) + await loginForm.locator('#password').fill(adminPassword) + await loginForm.getByRole('button', { name: 'Log in' }).click() + + await page.waitForURL(/\/main\/friends/) + + await expect(page.getByTitle('Administration')).toBeVisible() + await page.getByTitle('Administration').click() + + const modal = page.locator('.settings-modal-panel') + await expect( + modal.getByRole('heading', { name: 'Administration' }), + ).toBeVisible() + + await modal.getByRole('tab', { name: 'Emoji' }).click() + await expect(modal.getByText('Emoji packs')).toBeVisible() +}) diff --git a/test/e2e-playwright/specs/user_smoke.spec.js b/test/e2e-playwright/specs/user_smoke.spec.js new file mode 100644 index 000000000..a71378c06 --- /dev/null +++ b/test/e2e-playwright/specs/user_smoke.spec.js @@ -0,0 +1,90 @@ +import { randomUUID } from 'node:crypto' +import { expect, test } from 'playwright/test' + +const createTestUser = () => { + const id = randomUUID().slice(0, 8) + return { + username: `e2e_${id}`, + fullname: `E2E ${id}`, + email: `e2e_${id}@example.com`, + password: 'e2e-password', + } +} + +const register = async (page, user) => { + await page.goto('/registration') + + const registrationForm = page.locator('#main-scroller form.registration-form') + await registrationForm.locator('#sign-up-username').fill(user.username) + await registrationForm.locator('#sign-up-fullname').fill(user.fullname) + await registrationForm.locator('#email').fill(user.email) + await registrationForm.locator('#sign-up-password').fill(user.password) + await registrationForm + .locator('#sign-up-password-confirmation') + .fill(user.password) + await Promise.all([ + page.waitForURL(/\/main\/friends/), + registrationForm.getByRole('button', { name: 'Register' }).click(), + ]) +} + +const logout = async (page) => { + await page.getByTitle('Log out').click() + const confirmLogout = page.getByRole('button', { + name: 'Logout', + exact: true, + }) + if (await confirmLogout.isVisible()) { + await Promise.all([ + page.waitForURL(/\/main\/(public|all)/), + confirmLogout.click(), + ]) + } else { + await page.waitForURL(/\/main\/(public|all)/) + } + + await expect(page.locator('#sidebar form.login-form')).toBeVisible() +} + +const login = async (page, user) => { + await page.goto('/login') + + const loginForm = page.locator('#main-scroller form.login-form') + await loginForm.locator('#username').fill(user.username) + await loginForm.locator('#password').fill(user.password) + await loginForm.getByRole('button', { name: 'Log in' }).click() + + await page.waitForURL(/\/main\/friends/) +} + +test('user can register, log out, and log back in', async ({ page }) => { + const user = createTestUser() + await register(page, user) + await expect(page.getByTitle('Log out')).toBeVisible() + + await logout(page) + + await login(page, user) + await expect(page.getByTitle('Log out')).toBeVisible() +}) + +test('user can post a status', async ({ page }) => { + const user = createTestUser() + await register(page, user) + + const statusText = `Hello from ${user.username} (${randomUUID().slice(0, 8)})` + const composer = page.locator('#sidebar .user-panel .post-status-form') + await composer.locator('textarea.form-post-body').fill(statusText) + await Promise.all([ + page.waitForResponse( + (resp) => + resp.request().method() === 'POST' && + resp.url().includes('/api/v1/statuses') && + resp.ok(), + ), + composer.getByRole('button', { name: 'Post', exact: true }).click(), + ]) + + await page.goto(`/users/${user.username}`) + await expect(page.getByText(statusText)).toBeVisible() +}) diff --git a/tools/e2e/run.sh b/tools/e2e/run.sh new file mode 100644 index 000000000..3c0ba8a36 --- /dev/null +++ b/tools/e2e/run.sh @@ -0,0 +1,35 @@ +#!/bin/sh + +set -u + +COMPOSE_FILE="docker-compose.e2e.yml" + +: "${COMPOSE_MENU:=false}" +: "${PLEROMA_IMAGE:=git.pleroma.social:5050/pleroma/pleroma:stable}" +: "${E2E_ADMIN_USERNAME:=admin}" +: "${E2E_ADMIN_PASSWORD:=adminadmin}" +: "${E2E_ADMIN_EMAIL:=admin@example.com}" + +cleanup() { + docker compose -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true +} + +cleanup + +trap 'cleanup; exit 130' INT TERM + +set +e +COMPOSE_MENU="$COMPOSE_MENU" docker compose -f "$COMPOSE_FILE" up --build --remove-orphans --attach e2e --no-log-prefix --abort-on-container-exit --exit-code-from e2e +result="$?" +set -e + +if [ "$result" -ne 0 ]; then + docker compose -f "$COMPOSE_FILE" cp e2e:/app/test/e2e-playwright/test-results test/e2e-playwright >/dev/null 2>&1 || true + docker compose -f "$COMPOSE_FILE" cp e2e:/app/test/e2e-playwright/playwright-report test/e2e-playwright >/dev/null 2>&1 || true + mkdir -p test/e2e-playwright/test-results + docker compose -f "$COMPOSE_FILE" logs --no-color pleroma db > test/e2e-playwright/test-results/docker-compose.log 2>/dev/null || true +fi + +cleanup + +exit "$result" diff --git a/vite.config.js b/vite.config.js index 41468a089..3632ba3c1 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,6 +5,7 @@ import vueJsx from '@vitejs/plugin-vue-jsx' import { defineConfig } from 'vite' import eslint from 'vite-plugin-eslint2' import stylelint from 'vite-plugin-stylelint' +import { configDefaults } from 'vitest/config' import { getCommitHash } from './build/commit_hash.js' import copyPlugin from './build/copy_plugin.js' import emojisPlugin from './build/emojis_plugin.js' @@ -16,23 +17,37 @@ import { } from './build/sw_plugin.js' const localConfigPath = '/config/local.json' +const normalizeTarget = (target) => { + if (!target || typeof target !== 'string') return target + return target.endsWith('/') ? target.replace(/\/$/, '') : target +} + const getLocalDevSettings = async () => { + const envTarget = normalizeTarget(process.env.VITE_PROXY_TARGET) + const envOrigin = normalizeTarget(process.env.VITE_PROXY_ORIGIN) try { const settings = (await import('./config/local.json')).default - if (settings.target && settings.target.endsWith('/')) { - // replacing trailing slash since it can conflict with some apis - // and that's how actual BE reports its url - settings.target = settings.target.replace(/\/$/, '') - } + settings.target = normalizeTarget(settings.target) + settings.origin = normalizeTarget(settings.origin) + if (envTarget) settings.target = envTarget + if (envOrigin) settings.origin = envOrigin console.info(`Using local dev server settings (${localConfigPath}):`) console.info(JSON.stringify(settings, null, 2)) return settings } catch (e) { + if (!envTarget && !envOrigin) { + console.info( + `Local dev server settings not found (${localConfigPath}), using default`, + e, + ) + return {} + } + const settings = { target: envTarget, origin: envOrigin } console.info( - `Local dev server settings not found (${localConfigPath}), using default`, - e, + 'Using dev server settings from VITE_PROXY_TARGET/VITE_PROXY_ORIGIN:', ) - return {} + console.info(JSON.stringify(settings, null, 2)) + return settings } } @@ -58,6 +73,7 @@ const getTransformSWSettings = (settings) => { export default defineConfig(async ({ mode, command }) => { const settings = await getLocalDevSettings() const target = settings.target || 'http://localhost:4000/' + const origin = settings.origin || target const transformSW = getTransformSWSettings(settings) const proxy = { '/api': { @@ -77,7 +93,7 @@ export default defineConfig(async ({ mode, command }) => { cookieDomainRewrite: 'localhost', ws: true, headers: { - Origin: target, + Origin: origin, }, }, '/oauth': { @@ -225,6 +241,7 @@ export default defineConfig(async ({ mode, command }) => { }, test: { globals: true, + exclude: [...configDefaults.exclude, 'test/e2e-playwright/**'], browser: { enabled: true, provider: 'playwright',