Merge remote-tracking branch 'origin/develop' into biome

This commit is contained in:
Henry Jameson 2026-01-08 16:48:27 +02:00
commit 851c100a24
15 changed files with 576 additions and 65 deletions

12
.dockerignore Normal file
View file

@ -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__/

3
.gitignore vendored
View file

@ -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/

View file

@ -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 <<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 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
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:

View file

@ -0,0 +1 @@
Add playwright E2E-tests with an optional docker-based backend

57
docker-compose.e2e.yml Normal file
View file

@ -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"]

17
docker/e2e/Dockerfile.e2e Normal file
View file

@ -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"]

View file

@ -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"

View file

@ -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",

View file

@ -30,60 +30,61 @@
</p>
<div class="setting-control">
<table>
<tr>
<th>&nbsp;</th>
<th>
{{ $t('admin_dash.rate_limit.period') }}
</th>
<th>
{{ $t('admin_dash.rate_limit.amount') }}
</th>
</tr>
<tr>
<td v-if="isSeparate">
{{ $t('admin_dash.rate_limit.unauthenticated') }}
</td>
<td v-else>
{{ $t('admin_dash.rate_limit.rate_limit') }}
</td>
<td>
<input
class="input string-input"
type="number"
:value="normalizedState[0][0]"
@change="e => update({ event: e, index: 0, side: 0, eventType: 'edit' })"
>
</td>
<td>
<input
class="input string-input"
type="number"
:value="normalizedState[0][1]"
@change="e => update({ event: e, index: 1, side: 0, eventType: 'edit' })"
>
</td>
</tr>
<tr v-if="isSeparate">
<td>
{{ $t('admin_dash.rate_limit.authenticated') }}
</td>
<td>
<input
class="input string-input"
type="number"
:value="normalizedState[1][0]"
@change="e => update({ event: e, index: 0, side: 1, eventType: 'edit' })"
>
</td>
<td>
<input
class="input string-input"
type="number"
:value="normalizedState[1][1]"
@change="e => update({ event: e, index: 1, side: 1, eventType: 'edit' })"
>
</td>
</tr>
<thead>
<tr>
<th>&nbsp;</th>
<th>
{{ $t('admin_dash.rate_limit.period') }}
</th>
<th>
{{ $t('admin_dash.rate_limit.amount') }}
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
{{ isSeparate ? $t('admin_dash.rate_limit.unauthenticated') : $t('admin_dash.rate_limit.rate_limit') }}
</td>
<td>
<input
class="input string-input"
type="number"
:value="normalizedState[0][0]"
@change="e => update({ event: e, index: 0, side: 0, eventType: 'edit' })"
>
</td>
<td>
<input
class="input string-input"
type="number"
:value="normalizedState[0][1]"
@change="e => update({ event: e, index: 1, side: 0, eventType: 'edit' })"
>
</td>
</tr>
<tr v-if="isSeparate">
<td>
{{ $t('admin_dash.rate_limit.authenticated') }}
</td>
<td>
<input
class="input string-input"
type="number"
:value="normalizedState[1][0]"
@change="e => update({ event: e, index: 0, side: 1, eventType: 'edit' })"
>
</td>
<td>
<input
class="input string-input"
type="number"
:value="normalizedState[1][1]"
@change="e => update({ event: e, index: 1, side: 1, eventType: 'edit' })"
>
</td>
</tr>
</tbody>
</table>
<Checkbox
:model-value="isSeparate"

View file

@ -624,7 +624,7 @@ const users = {
if (data.access_token) {
store.commit('signUpSuccess')
oauthStore.setToken(data.access_token)
store.dispatch('loginUser', data.access_token)
await store.dispatch('loginUser', data.access_token)
return 'ok'
} else {
// Request succeeded, but user cannot login yet.

View file

@ -0,0 +1,50 @@
/* global process */
import { defineConfig, devices } from 'playwright/test'
const baseURL = process.env.E2E_BASE_URL || 'http://localhost:8080'
export default defineConfig({
testDir: './specs',
// Paths are resolved relative to this config file directory.
outputDir: 'test-results',
timeout: 60_000,
expect: {
timeout: 10_000,
},
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI
? [['line'], ['html', { outputFolder: 'playwright-report', open: 'never' }]]
: [
['list'],
['html', { outputFolder: 'playwright-report', open: 'never' }],
],
use: {
baseURL,
screenshot: 'only-on-failure',
trace: 'on-first-retry',
video: 'retain-on-failure',
},
webServer: {
command: 'yarn dev -- --host 0.0.0.0 --port 8080 --strictPort',
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
...process.env,
VITE_PROXY_TARGET:
process.env.VITE_PROXY_TARGET || 'http://localhost:4000',
VITE_PROXY_ORIGIN:
process.env.VITE_PROXY_ORIGIN ||
process.env.VITE_PROXY_TARGET ||
'http://localhost:4000',
},
},
projects: [
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
],
})

View file

@ -0,0 +1,27 @@
/* global process */
import { expect, test } from 'playwright/test'
const adminUsername = process.env.E2E_ADMIN_USERNAME || 'admin'
const adminPassword = process.env.E2E_ADMIN_PASSWORD || 'adminadmin'
test('admin can open the admin settings modal', async ({ page }) => {
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()
})

View file

@ -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()
})

35
tools/e2e/run.sh Normal file
View file

@ -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"

View file

@ -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 = '<projectRoot>/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',