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 99c85dd36..5045d8c68 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -60,6 +60,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 1caf69110..01a5fad37 100644
--- a/package.json
+++ b/package.json
@@ -10,11 +10,12 @@
"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",
"stylelint": "yarn exec stylelint '**/*.scss' '**/*.vue'",
- "lint": "eslint src test/unit/specs test/e2e/specs",
- "lint-fix": "eslint --fix src test/unit/specs test/e2e/specs"
+ "lint": "eslint src test/unit/specs test/e2e/specs test/e2e-playwright/specs test/e2e-playwright/playwright.config.mjs",
+ "lint-fix": "eslint --fix src test/unit/specs test/e2e/specs test/e2e-playwright/specs test/e2e-playwright/playwright.config.mjs"
},
"dependencies": {
"@babel/runtime": "7.28.4",
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') }}
- |
-
- update({ event: e, index: 0, side: 0, eventType: 'edit' })"
- >
- |
-
- update({ event: e, index: 1, side: 0, eventType: 'edit' })"
- >
- |
-
-
- |
- {{ $t('admin_dash.rate_limit.authenticated') }}
- |
-
- update({ event: e, index: 0, side: 1, eventType: 'edit' })"
- >
- |
-
- update({ event: e, index: 1, side: 1, eventType: 'edit' })"
- >
- |
-
+
+
+ | |
+
+ {{ $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') }}
+ |
+
+ update({ event: e, index: 0, side: 0, eventType: 'edit' })"
+ >
+ |
+
+ update({ event: e, index: 1, side: 0, eventType: 'edit' })"
+ >
+ |
+
+
+ |
+ {{ $t('admin_dash.rate_limit.authenticated') }}
+ |
+
+ update({ event: e, index: 0, side: 1, eventType: 'edit' })"
+ >
+ |
+
+ update({ event: e, index: 1, side: 1, eventType: 'edit' })"
+ >
+ |
+
+
{
+ 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..080a20d1a
--- /dev/null
+++ b/test/e2e-playwright/specs/user_smoke.spec.js
@@ -0,0 +1,80 @@
+import { randomUUID } from 'node:crypto'
+import { test, expect } 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 0b4d0db90..4bba3da86 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,6 +1,7 @@
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { defineConfig } from 'vite'
+import { configDefaults } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import stylelint from 'vite-plugin-stylelint'
@@ -12,20 +13,32 @@ import { getCommitHash } from './build/commit_hash.js'
import mswPlugin from './build/msw_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) {
- console.info(`Local dev server settings not found (${localConfigPath}), using default`, e)
- return {}
+ if (!envTarget && !envOrigin) {
+ console.info(`Local dev server settings not found (${localConfigPath}), using default`, e)
+ return {}
+ }
+ const settings = { target: envTarget, origin: envOrigin }
+ console.info('Using dev server settings from VITE_PROXY_TARGET/VITE_PROXY_ORIGIN:')
+ console.info(JSON.stringify(settings, null, 2))
+ return settings
}
}
@@ -51,6 +64,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': {
@@ -70,7 +84,7 @@ export default defineConfig(async ({ mode, command }) => {
cookieDomainRewrite: 'localhost',
ws: true,
headers: {
- 'Origin': target
+ 'Origin': origin
}
},
'/oauth': {
@@ -204,6 +218,10 @@ export default defineConfig(async ({ mode, command }) => {
},
test: {
globals: true,
+ exclude: [
+ ...configDefaults.exclude,
+ 'test/e2e-playwright/**'
+ ],
browser: {
enabled: true,
provider: 'playwright',