Migrate E2E suite from Cypress to Playwright (#4186)

* Migrate E2E suite from Cypress to Playwright

* Address review feedback for Playwright migration

Use Symfony Filesystem::mkdir() for the test uploads directory instead of
suppressed native mkdir, and tolerate an optional trailing slash in the
admin smoke test URL assertions.
This commit is contained in:
Adam Daley authored and GitHub committed 2026-08-23 22:23:06 +01:00
1 parent 8893808280
commit aa0f776160
36 files changed
+1031 -862

No files matched your search

-195
View File
@@ -1,195 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
image="${1:?Usage: run-docker-cypress-tests.sh <test-image>}"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "${script_dir}/../.." && pwd)"
compose_file="${repo_root}/.github/docker/live-tests.compose.yml"
project="fossbilling-cypress-${GITHUB_RUN_ID:-local}-$$"
cypress_image="${CYPRESS_DOCKER_IMAGE:-cypress/included:15.15.0}"
cypress_browser="${CYPRESS_BROWSER:-electron}"
app_host="fossbilling-app"
app_url="http://${app_host}/"
install_url="${app_url}install/"
db_name="fossbilling"
db_user="root"
db_pass="root"
db_port="3306"
test_email="email@example.com"
test_pass="4WGemqiihh8iM3"
test_api_key="AW6qEQCa7U7FG96J9NFIZXNYMJ79M8LH"
compose() {
docker compose --file "${compose_file}" --project-name "${project}" "$@"
}
cleanup() {
status=$?
if [[ -n "${cypress_pull_pid:-}" ]]; then
kill "${cypress_pull_pid}" 2>/dev/null || true
wait "${cypress_pull_pid}" 2>/dev/null || true
fi
if [[ $status -ne 0 ]]; then
compose exec -T app sh -c '
cd /var/www/html
for file in install/php_error.log data/log/php_error.log; do
if [ -f "$file" ]; then
echo "===== $file ====="
cat "$file"
fi
done
find data/log -type f ! -name "*.html" ! -name "php_error.log" -print 2>/dev/null | while read -r file; do
echo "===== $file ====="
cat "$file"
done
' || true
compose logs --no-color app db || true
fi
compose down --volumes --remove-orphans >/dev/null 2>&1 || true
exit "$status"
}
set_commit_info() {
if ! command -v git >/dev/null 2>&1 || ! git -C "${repo_root}" rev-parse --git-dir >/dev/null 2>&1; then
return
fi
if [[ -z "${COMMIT_INFO_BRANCH:-}" ]]; then
COMMIT_INFO_BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}"
if [[ -z "${COMMIT_INFO_BRANCH}" ]]; then
COMMIT_INFO_BRANCH="$(git -C "${repo_root}" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
if [[ "${COMMIT_INFO_BRANCH}" == "HEAD" ]]; then
COMMIT_INFO_BRANCH=""
fi
fi
fi
if [[ -z "${COMMIT_INFO_MESSAGE:-}" ]]; then
COMMIT_INFO_MESSAGE="$(git -C "${repo_root}" show -s --pretty=%B 2>/dev/null || true)"
fi
if [[ -z "${COMMIT_INFO_EMAIL:-}" ]]; then
COMMIT_INFO_EMAIL="$(git -C "${repo_root}" show -s --pretty=%ae 2>/dev/null || true)"
fi
if [[ -z "${COMMIT_INFO_AUTHOR:-}" ]]; then
COMMIT_INFO_AUTHOR="$(git -C "${repo_root}" show -s --pretty=%an 2>/dev/null || true)"
fi
if [[ -z "${COMMIT_INFO_SHA:-}" ]]; then
COMMIT_INFO_SHA="$(git -C "${repo_root}" rev-parse HEAD 2>/dev/null || true)"
fi
if [[ -z "${COMMIT_INFO_REMOTE:-}" ]]; then
COMMIT_INFO_REMOTE="$(git -C "${repo_root}" config --get remote.origin.url 2>/dev/null || true)"
fi
export COMMIT_INFO_BRANCH COMMIT_INFO_MESSAGE COMMIT_INFO_EMAIL COMMIT_INFO_AUTHOR COMMIT_INFO_SHA COMMIT_INFO_REMOTE
}
if ! docker image inspect "${image}" >/dev/null 2>&1; then
echo "Docker image '${image}' was not found. Build the test image before running Cypress tests."
exit 1
fi
set_commit_info
cypress_args=(run --browser "${cypress_browser}")
if [[ -n "${CYPRESS_RECORD_KEY:-}" ]]; then
cypress_args+=(--record --group "PHP 8.5 / ${cypress_browser}")
if [[ -n "${GITHUB_RUN_ID:-}" ]]; then
cypress_args+=(--ci-build-id "${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}")
fi
else
echo "CYPRESS_RECORD_KEY is not set. Running Cypress without Cloud recording."
fi
trap cleanup EXIT
docker pull "${cypress_image}" &
cypress_pull_pid=$!
export FOSSBILLING_TEST_IMAGE="${image}"
export FOSSBILLING_DB_NAME="${db_name}"
export FOSSBILLING_DB_PASS="${db_pass}"
compose up --detach
compose exec -T app rm -f /var/www/html/config.php
for _ in {1..60}; do
if compose exec -T app curl -fsS "${install_url}" >/dev/null; then
break
fi
sleep 2
done
compose exec -T app curl -fsS "${install_url}" >/dev/null
install_payload=(
-H 'Content-type: multipart/form-data'
-F error_reporting=0
-F "database_hostname=db"
-F "database_port=${db_port}"
-F "database_name=${db_name}"
-F "database_username=${db_user}"
-F "database_password=${db_pass}"
-F admin_name=test
-F "admin_email=${test_email}"
-F "admin_password=${test_pass}"
-F currency_code=USD
-F 'currency_title=US Dollar'
-F "admin_api_token=${test_api_key}"
-X POST
"${install_url}install.php?a=install"
)
compose exec -T app curl -fsS "${install_payload[@]}" >/dev/null
compose exec -T app php -r '
$configPath = "/var/www/html/config.php";
$config = require $configPath;
$config["security"]["perform_session_fingerprinting"] = false;
file_put_contents($configPath, "<?php\n\nreturn " . var_export($config, true) . ";\n");
'
wait "${cypress_pull_pid}"
cypress_pull_pid=""
# Chromium-based browsers are sensitive to Docker's default 64 MB /dev/shm size.
docker run --rm \
--network "${project}_default" \
--shm-size=2g \
--env CI \
--env CYPRESS_BASE_URL="${app_url}" \
--env CYPRESS_ADMIN_EMAIL="${test_email}" \
--env CYPRESS_ADMIN_PASSWORD="${test_pass}" \
--env CYPRESS_PROJECT_ID \
--env CYPRESS_RECORD_KEY \
--env COMMIT_INFO_AUTHOR \
--env COMMIT_INFO_BRANCH \
--env COMMIT_INFO_EMAIL \
--env COMMIT_INFO_MESSAGE \
--env COMMIT_INFO_REMOTE \
--env COMMIT_INFO_SHA \
--env GITHUB_ACTIONS \
--env GITHUB_API_URL \
--env GITHUB_BASE_REF \
--env GITHUB_EVENT_NAME \
--env GITHUB_HEAD_REF \
--env GITHUB_JOB \
--env GITHUB_REF \
--env GITHUB_REF_NAME \
--env GITHUB_REPOSITORY \
--env GITHUB_RUN_ATTEMPT \
--env GITHUB_RUN_ID \
--env GITHUB_SERVER_URL \
--env GITHUB_SHA \
--env GITHUB_WORKFLOW \
--volume "${repo_root}:/workspace" \
--workdir /workspace \
--entrypoint cypress \
"${cypress_image}" \
"${cypress_args[@]}"
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
set -euo pipefail
image="${1:?Usage: run-docker-playwright-tests.sh <test-image>}"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "${script_dir}/../.." && pwd)"
compose_file="${repo_root}/.github/docker/live-tests.compose.yml"
project="fossbilling-playwright-${GITHUB_RUN_ID:-local}-$$"
playwright_version="1.62.1"
playwright_image="${PLAYWRIGHT_DOCKER_IMAGE:-mcr.microsoft.com/playwright:v${playwright_version}-noble}"
app_host="fossbilling-app"
app_url="http://${app_host}/"
install_url="${app_url}install/"
db_name="fossbilling"
db_user="root"
db_pass="root"
db_port="3306"
test_email="email@example.com"
test_pass="4WGemqiihh8iM3"
test_api_key="AW6qEQCa7U7FG96J9NFIZXNYMJ79M8LH"
compose() {
docker compose --file "${compose_file}" --project-name "${project}" "$@"
}
cleanup() {
status=$?
if [[ -n "${playwright_pull_pid:-}" ]]; then
kill "${playwright_pull_pid}" 2>/dev/null || true
wait "${playwright_pull_pid}" 2>/dev/null || true
fi
if [[ $status -ne 0 ]]; then
compose exec -T app sh -c '
cd /var/www/html
for file in install/php_error.log data/log/php_error.log; do
if [ -f "$file" ]; then
echo "===== $file ====="
cat "$file"
fi
done
find data/log -type f ! -name "*.html" ! -name "php_error.log" -print 2>/dev/null | while read -r file; do
echo "===== $file ====="
cat "$file"
done
' || true
compose logs --no-color app db || true
fi
compose down --volumes --remove-orphans >/dev/null 2>&1 || true
exit "$status"
}
if ! docker image inspect "${image}" >/dev/null 2>&1; then
echo "Docker image '${image}' was not found. Build the test image before running Playwright tests."
exit 1
fi
trap cleanup EXIT
docker pull "${playwright_image}" &
playwright_pull_pid=$!
export FOSSBILLING_TEST_IMAGE="${image}"
export FOSSBILLING_DB_NAME="${db_name}"
export FOSSBILLING_DB_PASS="${db_pass}"
compose up --detach
compose exec -T app rm -f /var/www/html/config.php
for _ in {1..60}; do
if compose exec -T app curl -fsS "${install_url}" >/dev/null; then
break
fi
sleep 2
done
compose exec -T app curl -fsS "${install_url}" >/dev/null
install_payload=(
-H 'Content-type: multipart/form-data'
-F error_reporting=0
-F "database_hostname=db"
-F "database_port=${db_port}"
-F "database_name=${db_name}"
-F "database_username=${db_user}"
-F "database_password=${db_pass}"
-F admin_name=test
-F "admin_email=${test_email}"
-F "admin_password=${test_pass}"
-F currency_code=USD
-F 'currency_title=US Dollar'
-F "admin_api_token=${test_api_key}"
-X POST
"${install_url}install.php?a=install"
)
compose exec -T app curl -fsS "${install_payload[@]}" >/dev/null
compose exec -T app php -r '
$configPath = "/var/www/html/config.php";
$config = require $configPath;
$config["security"]["perform_session_fingerprinting"] = false;
file_put_contents($configPath, "<?php\n\nreturn " . var_export($config, true) . ";\n");
'
wait "${playwright_pull_pid}"
playwright_pull_pid=""
# Chromium-based browsers need more shared memory than Docker's default /dev/shm.
# The runner package is installed into a container-local prefix; a tmpfs shadows the
# workspace's @playwright directory so specs resolve that exact copy (one package
# instance) without touching the host's node_modules.
docker run --rm \
--network "${project}_default" \
--shm-size=2g \
--tmpfs /workspace/node_modules/@playwright:rw \
--env CI \
--env PLAYWRIGHT_BASE_URL="${app_url}" \
--env ADMIN_EMAIL="${test_email}" \
--env ADMIN_PASSWORD="${test_pass}" \
--env GITHUB_ACTIONS \
--volume "${repo_root}:/workspace" \
--workdir /workspace \
"${playwright_image}" \
bash -c '
set -euo pipefail
runner=/opt/playwright-runner
mkdir -p "$runner" && cd "$runner"
npm init -y >/dev/null
npm install --no-audit --no-fund "@playwright/test@'"${playwright_version}"'" >/dev/null
ln -sn "$runner/node_modules/@playwright/test" /workspace/node_modules/@playwright/test
cd /workspace
exec "$runner/node_modules/.bin/playwright" test
'
+9 -17
View File
@@ -116,35 +116,27 @@ jobs:
if: ${{ github.event_name == 'push' }}
uses: ./.github/workflows/live-tests.yml
cypress-tests-pr:
name: 'Cypress Tests'
playwright-tests-pr:
name: 'Playwright Tests'
permissions:
contents: read
needs: [ php-build-test-pr ]
if: ${{ github.event_name == 'pull_request' && github.event.action != 'labeled' && github.event.action != 'unlabeled' && github.event.pull_request.base.repo.id != github.event.pull_request.head.repo.id }}
uses: ./.github/workflows/cypress-tests.yml
with:
cypress-project-id: ${{ vars.CYPRESS_PROJECT_ID }}
secrets:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
uses: ./.github/workflows/playwright-tests.yml
cypress-tests-push:
name: 'Cypress Tests'
playwright-tests-push:
name: 'Playwright Tests'
permissions:
contents: read
needs: [ php-build-test-push ]
if: ${{ github.event_name == 'push' }}
uses: ./.github/workflows/cypress-tests.yml
with:
cypress-project-id: ${{ vars.CYPRESS_PROJECT_ID }}
secrets:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
uses: ./.github/workflows/playwright-tests.yml
preview-build-pr:
name: 'Create Preview Build'
permissions:
contents: read
needs: [ live-tests-pr, cypress-tests-pr ]
needs: [ live-tests-pr, playwright-tests-pr ]
if: ${{ github.event_name == 'pull_request' && github.event.action != 'labeled' && github.event.action != 'unlabeled' && github.event.pull_request.base.repo.id != github.event.pull_request.head.repo.id }}
uses: ./.github/workflows/preview-build.yml
with:
@@ -155,7 +147,7 @@ jobs:
permissions:
contents: read
packages: write
needs: [ live-tests-push, cypress-tests-push ]
needs: [ live-tests-push, playwright-tests-push ]
if: ${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
uses: ./.github/workflows/preview-build.yml
with:
@@ -167,7 +159,7 @@ jobs:
permissions:
contents: read
packages: write
needs: [ live-tests-push, cypress-tests-push ]
needs: [ live-tests-push, playwright-tests-push ]
if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
uses: ./.github/workflows/docker-build.yml
secrets:
@@ -1,29 +1,28 @@
name: Cypress Tests
name: Playwright Tests
on:
workflow_call:
inputs:
cypress-project-id:
required: false
type: string
default: ''
secrets:
CYPRESS_RECORD_KEY:
required: false
jobs:
cypress-tests:
name: 'Cypress Tests'
playwright-tests:
name: 'Playwright Tests'
runs-on: ubuntu-latest
timeout-minutes: 30
env:
CYPRESS_RECORDING_DISABLED: ${{ secrets.CYPRESS_RECORD_KEY == '' }}
steps:
- name: 'Checkout Repository'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: 'Set up Node'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
cache: npm
- name: 'Typecheck Test Suite'
run: npm ci --no-audit --no-fund && npm run pw:tsc
- name: 'Set up Docker Compose'
uses: docker/setup-compose-action@4eb059ff7f16592f9c84d5ca339c53cb7c5064e2 # v2.3.0
@@ -37,19 +36,14 @@ jobs:
- name: 'Load Test Image'
run: zstd --decompress --stdout /tmp/test-image-8.5.tar.zst | docker load
- name: 'Run Cypress Tests'
env:
CYPRESS_PROJECT_ID: ${{ inputs.cypress-project-id }}
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
run: bash ./.github/scripts/run-docker-cypress-tests.sh fossbilling:test-8.5
- name: 'Run Playwright Tests'
run: bash ./.github/scripts/run-docker-playwright-tests.sh fossbilling:test-8.5
- name: 'Upload Cypress Artifacts'
if: ${{ failure() && env.CYPRESS_RECORDING_DISABLED == 'true' }}
- name: 'Upload Playwright Artifacts'
if: ${{ failure() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: cypress-artifacts
name: playwright-artifacts
if-no-files-found: ignore
path: |
tests/E2E/Cypress/artifacts/screenshots
tests/E2E/Cypress/artifacts/videos
tests/E2E/Cypress/artifacts/downloads
tests/E2E/Playwright/artifacts
+2 -2
View File
@@ -29,5 +29,5 @@ src/install/php_error.log
src/themes/huraga/config/settings_data.json
output.log
### Cypress ###
tests/E2E/Cypress/artifacts
### Playwright ###
tests/E2E/Playwright/artifacts
+1 -1
View File
@@ -143,7 +143,7 @@ composer test
The default suite covers unit tests in `tests/Unit` and module unit tests in `src/modules/*/tests/Unit`.
Live API tests are in `src/modules/*/tests/E2E/` and run through Pest via the "E2E" test suite in `phpunit.xml.dist` when `APP_URL` and `TEST_API_KEY` are available.
Cypress end-to-end tests live under `tests/E2E/Cypress`.
Playwright end-to-end tests live under `tests/E2E/Playwright`. Run them locally with `npm run pw:open` (UI mode) or `npm run pw:run`; they target the app at `PLAYWRIGHT_BASE_URL` and require `ADMIN_EMAIL` and `ADMIN_PASSWORD` for admin flows.
### Code Quality Tools
+1 -1
View File
@@ -83,7 +83,7 @@ COPY package.json package-lock.json ./
COPY src/themes/admin_default/package.json src/themes/admin_default/package.json
COPY src/themes/huraga/package.json src/themes/huraga/package.json
RUN --mount=type=cache,target=/root/.npm CYPRESS_INSTALL_BINARY=0 npm ci
RUN --mount=type=cache,target=/root/.npm npm ci
COPY tsconfig.json ./
COPY src/themes/admin_default ./src/themes/admin_default
+2 -1
View File
@@ -188,6 +188,7 @@
"servicedomain",
"markitup",
"ckeditor",
"contenteditable",
"codexplorer",
"Xplorer",
"POSTFIELDS",
@@ -436,7 +437,7 @@
"Prins",
"iplookup",
"DDEV",
"Cypress",
"Playwright",
"CSRF",
"undismissed",
"unmigrated",
-20
View File
@@ -1,20 +0,0 @@
module.exports = {
projectId: 'zap1kb',
e2e: {
baseUrl: process.env.CYPRESS_BASE_URL || 'http://localhost',
specPattern: 'tests/E2E/Cypress/specs/**/*.cy.js',
supportFile: 'tests/E2E/Cypress/support/e2e.js',
},
defaultCommandTimeout: 10000,
viewportWidth: 1280,
viewportHeight: 720,
env: {
adminEmail: process.env.CYPRESS_ADMIN_EMAIL || '',
adminPassword: process.env.CYPRESS_ADMIN_PASSWORD || '',
},
video: false,
screenshotOnRunFailure: true,
screenshotsFolder: 'tests/E2E/Cypress/artifacts/screenshots',
videosFolder: 'tests/E2E/Cypress/artifacts/videos',
downloadsFolder: 'tests/E2E/Cypress/artifacts/downloads',
};
+64
View File
@@ -11,6 +11,7 @@
"src/themes/admin_default"
],
"devDependencies": {
"@playwright/test": "1.62.1",
"@types/node": "24.13.3",
"autoprefixer": "10.5.4",
"ckeditor5": "48.4.0",
@@ -1773,6 +1774,22 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -3449,6 +3466,21 @@
"dev": true,
"license": "ISC"
},
"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/fuzzysort": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz",
@@ -4955,6 +4987,38 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+4 -3
View File
@@ -12,11 +12,11 @@
"build-admin_default": "npm run build -w admin_default",
"check": "npm run check-types && npm run check-icons",
"check-types": "tsc --noEmit",
"pw:tsc": "tsc --noEmit -p tests/E2E/Playwright",
"check-icons": "node ./frontend/tools/check-icons.mts",
"test:js": "node --test 'frontend/core/tests/**/*.test.mts' 'frontend/tools/tests/**/*.test.mts'",
"cy:open": "npm exec --yes cypress@15.15.0 -- open",
"cy:run": "npm exec --yes cypress@15.15.0 -- run",
"cy:run:record": "npm exec --yes cypress@15.15.0 -- run --record"
"pw:open": "playwright test --ui",
"pw:run": "playwright test"
},
"repository": {
"type": "git",
@@ -30,6 +30,7 @@
"src/themes/admin_default"
],
"devDependencies": {
"@playwright/test": "1.62.1",
"@types/node": "24.13.3",
"autoprefixer": "10.5.4",
"ckeditor5": "48.4.0",
+37
View File
@@ -0,0 +1,37 @@
import { defineConfig, devices } from '@playwright/test';
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost';
export default defineConfig({
testDir: './tests/E2E/Playwright/specs',
timeout: 60_000,
expect: {
timeout: 10_000,
},
workers: 1,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI
? [
['github'],
['html', { outputFolder: 'tests/E2E/Playwright/artifacts/report', open: 'never' }],
]
: [
['list'],
['html', { outputFolder: 'tests/E2E/Playwright/artifacts/report', open: 'on-failure' }],
],
outputDir: 'tests/E2E/Playwright/artifacts/results',
use: {
baseURL,
actionTimeout: 10_000,
navigationTimeout: 30_000,
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
+1 -3
View File
@@ -249,9 +249,7 @@ function preInit(): void
$pathUploads = Path::join(PATH_ROOT, 'data', 'uploads');
if (getenv('APP_ENV') === 'test') {
$pathUploads = Path::join(sys_get_temp_dir(), 'fossbilling_test_data', 'uploads');
if (!is_dir($pathUploads) && !mkdir($pathUploads, 0o755, true) && !is_dir($pathUploads)) {
throw new Exception(sprintf('Unable to create uploads directory for tests: "%s".', $pathUploads));
}
(new Filesystem())->mkdir($pathUploads, 0o755);
}
define('PATH_UPLOADS', $pathUploads);
define('PATH_CONFIG', Path::join(PATH_ROOT, 'config.php'));
@@ -1,41 +0,0 @@
describe('admin auth and API protections', () => {
it('redirects logged-out admin pages to the staff login', () => {
cy.clearCookies();
cy.clearLocalStorage();
cy.visit('/admin/client');
cy.location('pathname').should('match', /^\/admin\/staff\/login\/?$/);
cy.contains('body', 'Login').should('be.visible');
});
it('allows authenticated admin profile API requests', () => {
cy.loginAsAdmin();
cy.getCookie('fossbilling_csrf').should('exist').then((cookie) => {
cy.request({
url: '/api/admin/profile/get',
qs: { CSRFToken: cookie.value },
}).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.error).to.eq(null);
expect(response.body.result.id, 'admin id').to.exist;
expect(response.body.result.email, 'admin email').to.eq(Cypress.env('adminEmail'));
});
});
});
it('rejects admin API POST requests without a CSRF token', () => {
cy.loginAsAdmin();
cy.request({
method: 'POST',
url: '/api/admin/profile/get',
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(403);
expect(response.body.result).to.eq(null);
expect(response.body.error.message).to.eq('CSRF token invalid');
});
});
});
@@ -1,7 +0,0 @@
describe('admin authentication', () => {
it('logs in as the installed administrator', () => {
cy.loginAsAdmin();
cy.title().should('include', 'Dashboard');
cy.contains('body', 'Clients').should('be.visible');
});
});
@@ -1,21 +0,0 @@
describe('admin smoke pages', () => {
const pages = [
'/admin',
'/admin/client',
'/admin/order',
'/admin/invoice',
'/admin/product',
'/admin/system',
'/admin/extension',
];
it('loads core admin pages successfully', () => {
cy.loginAsAdmin();
pages.forEach((path) => {
cy.visit(path);
cy.location('pathname').should('eq', path);
cy.get('body').should('be.visible');
});
});
});
@@ -1,30 +0,0 @@
describe('client area authentication protections', () => {
const protectedPages = [
'/client',
'/client/profile',
'/order/service',
'/invoice',
'/support',
'/email',
];
protectedPages.forEach((path) => {
it(`redirects logged-out requests for ${path} to the client login`, () => {
cy.clearCookies();
cy.clearLocalStorage();
cy.request({
url: path,
followRedirect: false,
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(302);
expect(response.headers.location).to.match(/\/login\/?$/);
});
cy.visit(path);
cy.location('pathname').should('match', /^\/login\/?$/);
cy.contains('body', 'Login to Your Account').should('be.visible');
});
});
});
@@ -1,36 +0,0 @@
describe('client authentication', () => {
it('creates a new client account and automatically logs in', () => {
const suffix = `${Date.now()}-${Cypress._.random(100000, 999999)}`;
const client = {
first_name: 'Cypress',
last_name: 'Signup',
email: `cypress-signup-${suffix}@example.com`,
password: 'CypressClient1!',
};
cy.visit('/signup');
cy.contains('body', 'Create a new account').should('be.visible');
cy.intercept('POST', '**/api/guest/client/create*').as('clientSignup');
cy.fillClientSignupForm(client);
cy.get('form[action*="/api/guest/client/create"]').submit();
cy.wait('@clientSignup').then(({ response }) => {
expect(response.statusCode).to.eq(200);
expect(response.body.error).to.eq(null);
expect(response.body.result, 'client id').to.exist;
});
cy.location('pathname', { timeout: 10000 }).should('eq', '/');
cy.getCookie('fossbilling_csrf').should('exist').then((cookie) => {
cy.request({
url: '/api/client/profile/get',
qs: { CSRFToken: cookie.value },
}).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.error).to.eq(null);
expect(response.body.result.email).to.eq(client.email);
});
});
});
});
@@ -1,100 +0,0 @@
describe('client profile', () => {
let client;
beforeEach(() => {
cy.testClient().then((testClient) => {
client = testClient;
cy.loginAsClient(client);
});
});
it('updates profile details', () => {
const updatedProfile = {
firstName: 'Updated',
lastName: 'Profile',
company: 'Updated Cypress Company',
country: 'GB',
phoneCountryCode: '44',
phone: '7700900123',
phoneDisplay: '7700 900123',
address: '42 Updated Road',
city: 'Updated City',
state: 'Updated State',
postcode: 'UP123',
};
cy.visit('/client/profile');
cy.contains('body', 'Update Details').should('be.visible');
cy.intercept('POST', '**/api/client/profile/update*').as('profileUpdate');
cy.get('form#profile-update').within(() => {
cy.get('input[name="first_name"]').clear().type(updatedProfile.firstName);
cy.get('input[name="last_name"]').clear().type(updatedProfile.lastName);
cy.get('input[name="company"]').clear().type(updatedProfile.company);
cy.get('select[name="country"]').select(updatedProfile.country);
cy.get('input[name="phone"]').clear().type(updatedProfile.phone);
cy.get('input[name="address_1"]').clear().type(updatedProfile.address);
cy.get('input[name="city"]').clear().type(updatedProfile.city);
cy.get('input[name="state"]').clear().type(updatedProfile.state);
cy.get('input[name="postcode"]').clear().type(updatedProfile.postcode);
cy.root().submit();
});
cy.wait('@profileUpdate').then(({ response }) => {
expect(response.statusCode).to.eq(200);
expect(response.body.error).to.eq(null);
expect(response.body.result).to.eq(true);
});
cy.reload();
cy.get('input[name="first_name"]').should('have.value', updatedProfile.firstName);
cy.get('input[name="last_name"]').should('have.value', updatedProfile.lastName);
cy.get('input[name="company"]').should('have.value', updatedProfile.company);
cy.get('select[name="country"]').should('have.value', updatedProfile.country);
cy.get('input[name="phone_cc"]').should('have.value', updatedProfile.phoneCountryCode);
cy.get('input[name="phone"]').should('have.value', updatedProfile.phoneDisplay);
cy.get('input[name="address_1"]').should('have.value', updatedProfile.address);
cy.get('input[name="city"]').should('have.value', updatedProfile.city);
cy.get('input[name="state"]').should('have.value', updatedProfile.state);
cy.get('input[name="postcode"]').should('have.value', updatedProfile.postcode);
});
it('changes the client password', () => {
const oldPassword = client.password;
const newPassword = 'CypressClient2!';
cy.visit('/client/profile');
cy.get('#pass-tab').click();
cy.intercept('POST', '**/api/client/profile/change_password*').as('passwordChange');
cy.get('#pass-tab-pane form').within(() => {
cy.get('input[name="current_password"]').type(oldPassword, { log: false });
cy.get('input[name="new_password"]').type(newPassword, { log: false });
cy.get('input[name="confirm_password"]').type(newPassword, { log: false });
cy.root().submit();
});
cy.wait('@passwordChange').then(({ response }) => {
expect(response.statusCode).to.eq(200);
expect(response.body.error).to.eq(null);
expect(response.body.result).to.eq(true);
});
cy.request({
method: 'POST',
url: '/api/guest/client/login',
body: {
email: client.email,
password: oldPassword,
},
failOnStatusCode: false,
}).then((response) => {
expect(response.body.result).to.eq(null);
expect(response.body.error.message).to.eq('Please check your login details.');
});
cy.clearCookies();
cy.clearLocalStorage();
cy.loginAsClient({ ...client, password: newPassword });
});
});
@@ -1,30 +0,0 @@
describe('client smoke pages', () => {
let client;
const pages = [
{ path: '/', text: 'Dashboard' },
{ path: '/client/profile', text: 'Update Details' },
{ path: '/order/service', text: 'Services' },
{ path: '/invoice', text: 'Invoices' },
{ path: '/support', text: 'Support Tickets' },
{ path: '/email', text: 'Emails' },
];
before(() => {
cy.testClient().then((testClient) => {
client = testClient;
});
});
it('loads core client pages successfully', () => {
cy.loginAsClient(client);
pages.forEach(({ path, text }) => {
cy.request(path).its('status').should('eq', 200);
cy.visit(path);
cy.get('body').should('be.visible');
cy.contains('body', text).should('be.visible');
});
});
});
@@ -1,83 +0,0 @@
describe('client support tickets', () => {
let client;
before(() => {
cy.testClient().then((testClient) => {
client = testClient;
});
});
it('opens, replies to, and closes a support ticket', () => {
const suffix = `${Date.now()}-${Cypress._.random(100000, 999999)}`;
const subject = `Cypress support lifecycle ${suffix}`;
const initialMessage = `Initial support request from Cypress ${suffix}.`;
const replyMessage = `Follow-up support reply from Cypress ${suffix}.`;
let ticketId;
cy.loginAsClient(client);
cy.visit('/support');
cy.contains('body', 'Support Tickets').should('be.visible');
cy.intercept('POST', '**/api/client/support/ticket_create*').as('ticketCreate');
cy.openBootstrapModal('[data-bs-target="#open-ticket-modal"]', '#open-ticket-modal');
cy.get('#open-ticket-modal select[name="support_helpdesk_id"]').invoke('val').should('not.be.empty');
cy.setEditorContent('#open-ticket-modal textarea[name="content"]', initialMessage);
cy.typeAndVerify('#open-ticket-modal input[name="subject"]', subject);
cy.get('#ticket-submit').submit();
cy.wait('@ticketCreate').then(({ request, response }) => {
const requestBody = typeof request.body === 'string' ? JSON.parse(request.body) : request.body;
expect(requestBody.subject).to.eq(subject);
expect(response.statusCode).to.eq(200);
expect(response.body.error).to.eq(null);
expect(response.body.result, 'ticket id').to.exist;
ticketId = response.body.result;
});
cy.then(() => {
cy.location('pathname', { timeout: 10000 }).should('eq', `/support/ticket/${ticketId}`);
cy.contains('body', subject).should('be.visible');
cy.contains('article.markdown-body', initialMessage).should('be.visible');
});
cy.intercept('POST', '**/api/client/support/ticket_reply*').as('ticketReply');
cy.setEditorContent('#ticket-reply-text', replyMessage);
cy.get('#ticket-reply-form').submit();
cy.wait('@ticketReply').then(({ response }) => {
expect(response.statusCode).to.eq(200);
expect(response.body.error).to.eq(null);
expect(response.body.result).to.eq(true);
});
cy.contains('article.markdown-body', replyMessage, { timeout: 10000 }).should('be.visible');
cy.intercept('POST', '**/api/client/support/ticket_close*').as('ticketClose');
cy.contains('button', 'Close Ticket').click();
cy.wait('@ticketClose').then(({ response }) => {
expect(response.statusCode).to.eq(200);
expect(response.body.error).to.eq(null);
expect(response.body.result).to.eq(true);
});
cy.contains('button', 'Close Ticket').should('not.exist');
cy.then(() => {
cy.getCookie('fossbilling_csrf').should('exist').then((cookie) => {
cy.request({
url: '/api/client/support/ticket_get',
qs: {
CSRFToken: cookie.value,
id: ticketId,
},
}).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.error).to.eq(null);
expect(response.body.result.status).to.eq('closed');
expect(response.body.result.subject).to.eq(subject);
expect(response.body.result.messages).to.have.length(2);
});
});
});
});
});
@@ -1,20 +0,0 @@
describe('public pages', () => {
const pages = [
{ path: '/', text: null },
{ path: '/login', text: 'Login to Your Account' },
{ path: '/signup', text: 'Create a new account' },
{ path: '/password-reset', text: 'Reset Your Password' },
{ path: '/order', text: null },
];
pages.forEach(({ path, text }) => {
it(`loads ${path}`, () => {
cy.visit(path);
cy.get('body').should('be.visible');
if (text) {
cy.contains('body', text).should('be.visible');
}
});
});
});
-227
View File
@@ -1,227 +0,0 @@
Cypress.Commands.add('loginAsAdmin', () => {
const email = Cypress.env('adminEmail');
const password = Cypress.env('adminPassword');
expect(email, 'admin email').to.be.a('string').and.not.be.empty;
expect(password, 'admin password').to.be.a('string').and.not.be.empty;
cy.session(['admin', email], () => {
cy.visit('/admin/staff/login');
cy.intercept('POST', '**/api/guest/staff/login*').as('staffLogin');
cy.get('form[action*="/api/guest/staff/login"]').within(() => {
cy.get('input[name="email"]').clear().type(email);
cy.get('input[name="password"]').clear().type(password, { log: false });
cy.root().submit();
});
cy.wait('@staffLogin').its('response.statusCode').should('eq', 200);
cy.location('pathname', { timeout: 10000 }).should('match', /^\/admin\/?$/);
}, {
cacheAcrossSpecs: true,
validate() {
cy.request({ url: '/admin', followRedirect: false }).its('status').should('eq', 200);
},
});
cy.visit('/admin');
cy.location('pathname', { timeout: 10000 }).should('match', /^\/admin\/?$/);
});
const defaultClientPassword = 'CypressClient1!';
const uniqueSuffix = () => `${Date.now()}-${Cypress._.random(100000, 999999)}`;
Cypress.Commands.add('testClient', (overrides = {}) => {
const suffix = uniqueSuffix();
const client = {
first_name: overrides.first_name || 'Cypress',
last_name: overrides.last_name || 'Client',
email: overrides.email || `cypress-client-${suffix}@example.com`,
password: overrides.password || defaultClientPassword,
};
return cy.request({
method: 'POST',
url: '/api/guest/client/create',
body: {
...client,
password_confirm: client.password,
},
}).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.error).to.eq(null);
expect(response.body.result, 'client id').to.exist;
const createdClient = {
...client,
id: response.body.result,
};
return cy.clearCookies()
.then(() => cy.clearLocalStorage())
.then(() => createdClient);
});
});
Cypress.Commands.add('fillClientSignupForm', (client) => {
const values = {
first_name: client.first_name || 'Cypress',
last_name: client.last_name || 'Client',
email: client.email,
company: client.company || 'FOSSBilling Test Company',
birthday: client.birthday || '1990-01-01',
address_1: client.address_1 || '1 Cypress Street',
address_2: client.address_2 || 'Suite 2',
city: client.city || 'Test City',
state: client.state || 'Test State',
postcode: client.postcode || '12345',
phone_cc: client.phone_cc || '1',
phone: client.phone || '5551234567',
password: client.password,
password_confirm: client.password,
};
cy.get('form[action*="/api/guest/client/create"]').then(($form) => {
Object.entries(values).forEach(([name, value]) => {
const $field = $form.find(`[name="${name}"]`).first();
if ($field.length && ($field.attr('type') || '').toLowerCase() !== 'hidden') {
cy.wrap($field).clear().type(value, name.includes('password') ? { log: false } : {});
}
});
$form.find('input[required], textarea[required]').each((index, field) => {
const $field = Cypress.$(field);
const type = ($field.attr('type') || '').toLowerCase();
const name = $field.attr('name');
if (!name || Object.prototype.hasOwnProperty.call(values, name) || ['checkbox', 'radio', 'hidden', 'password'].includes(type)) {
return;
}
if (!$field.val()) {
cy.wrap(field).clear().type('Cypress test value');
}
});
const $gender = $form.find('select[name="gender"]').first();
if ($gender.length) {
cy.wrap($gender).select('other');
}
$form.find('select[required]').each((index, select) => {
const $select = Cypress.$(select);
if ($select.val()) {
return;
}
const firstValue = $select.find('option[value!=""]').first().val();
if (firstValue) {
cy.wrap(select).select(firstValue);
}
});
const $country = $form.find('select[name="country"]').first();
if ($country.length && !$country.val()) {
const firstCountry = $country.find('option[value!=""]').first().val();
if (firstCountry) {
cy.wrap($country).select(firstCountry);
}
}
$form.find('input[type="checkbox"][required]').each((index, checkbox) => {
cy.wrap(checkbox).check({ force: true });
});
});
});
Cypress.Commands.add('loginAsClient', (client) => {
expect(client.email, 'client email').to.be.a('string').and.not.be.empty;
expect(client.password, 'client password').to.be.a('string').and.not.be.empty;
cy.session(['client', client.email, client.password], () => {
cy.visit('/login');
cy.intercept('POST', '**/api/guest/client/login*').as('clientLogin');
cy.get('form[action*="/api/guest/client/login"]').within(() => {
cy.get('input[name="email"]').clear().type(client.email);
cy.get('input[name="password"]').clear().type(client.password, { log: false });
cy.root().submit();
});
cy.wait('@clientLogin').then(({ response }) => {
expect(response.statusCode).to.eq(200);
expect(response.body.error).to.eq(null);
});
cy.location('pathname', { timeout: 10000 }).should('eq', '/');
}, {
cacheAcrossSpecs: true,
validate() {
cy.getCookie('fossbilling_csrf').should('exist').then((cookie) => {
cy.request({
url: '/api/client/profile/get',
qs: { CSRFToken: cookie.value },
}).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.error).to.eq(null);
expect(response.body.result.email).to.eq(client.email);
});
});
},
});
cy.visit('/');
cy.contains('body', client.email).should('be.visible');
});
Cypress.Commands.add('setEditorContent', (selector, content) => {
cy.get(selector)
.should(($field) => {
expect($field[0].editor, `${selector} editor`).to.exist;
})
.then(($field) => {
const field = $field[0];
field.editor.setData(content);
field.value = content;
field.dispatchEvent(new Event('input', { bubbles: true }));
field.dispatchEvent(new Event('change', { bubbles: true }));
});
});
Cypress.Commands.add('openBootstrapModal', (triggerSelector, modalSelector) => {
let modalShown;
cy.window().then((win) => {
const modal = win.document.querySelector(modalSelector);
expect(modal, `${modalSelector} modal`).to.exist;
modalShown = modal.classList.contains('show')
? Cypress.Promise.resolve()
: new Cypress.Promise((resolve) => {
modal.addEventListener('shown.bs.modal', resolve, { once: true });
});
});
cy.get(triggerSelector).should('be.visible').click();
cy.then(() => modalShown);
cy.get(modalSelector).should('be.visible').and('have.class', 'show');
});
Cypress.Commands.add('typeAndVerify', (selector, value, options = {}) => {
cy.get(selector)
.should('be.visible')
.and('not.be.disabled')
.clear()
.type(value, { delay: 0, ...options })
.then(($input) => {
const el = $input[0];
const rawMaxLength = el.getAttribute('maxlength');
const maxLength = rawMaxLength === null ? -1 : Number.parseInt(rawMaxLength, 10);
const expectedValue = Number.isInteger(maxLength) && maxLength >= 0
? String(value).slice(0, maxLength)
: String(value);
cy.wrap($input).should('have.value', expectedValue);
});
});
+230
View File
@@ -0,0 +1,230 @@
import {
test as base,
expect,
type Browser,
type BrowserContext,
type Page,
} from '@playwright/test';
import { createTestClient, type TestClient } from '../helpers/client-factory';
import { submitForm } from '../helpers/forms';
type SessionState = Awaited<ReturnType<BrowserContext['storageState']>>;
interface Credentials {
email: string;
password: string;
}
export function adminCredentials(): Credentials {
const email = process.env.ADMIN_EMAIL;
const password = process.env.ADMIN_PASSWORD;
if (!email || !password) {
throw new Error('The ADMIN_EMAIL and ADMIN_PASSWORD environment variables must be set to run the admin tests.');
}
return { email, password };
}
async function csrfToken(context: BrowserContext): Promise<string> {
const cookie = (await context.cookies()).find(({ name }) => name === 'fossbilling_csrf');
if (!cookie) {
throw new Error('The fossbilling_csrf cookie was not found in the browser context.');
}
return cookie.value;
}
export async function authenticatedGet(context: BrowserContext, url: string, params: Record<string, unknown> = {}) {
return context.request.get(url, {
params: {
...params,
CSRFToken: await csrfToken(context),
},
});
}
/**
* Performs a login through the real login form and waits for the backing API call.
* Only the response status is asserted here; the response body cannot be read
* reliably once the login redirect navigates away. Session validity is verified
* separately by the caller.
*/
async function loginThroughForm(
page: Page,
options: {
visitPath: string;
apiPath: string;
credentials: Credentials;
expectedPath: RegExp;
},
): Promise<void> {
const { visitPath, apiPath, credentials, expectedPath } = options;
await page.goto(visitPath);
const form = page.locator(`form[action*="${apiPath}"]`);
await form.locator('input[name="email"]').fill(credentials.email);
await form.locator('input[name="password"]').fill(credentials.password);
const loginResponse = page.waitForResponse(
(response) =>
response.request().method() === 'POST' && new URL(response.url()).pathname.includes(apiPath),
);
await submitForm(form);
expect((await loginResponse).status(), `${apiPath} login response status`).toBe(200);
await expect(page).toHaveURL(
(url) => expectedPath.test(new URL(url).pathname),
{ timeout: 10_000 },
);
}
/**
* Caches login storage state promises per worker process. A failed login evicts its
* entry so the next test retries instead of reusing the failed promise.
*/
const storageStates = new Map<string, Promise<SessionState>>();
function cachedStorageState(key: string, build: () => Promise<SessionState>): Promise<SessionState> {
if (!storageStates.has(key)) {
const creation = build().catch((error) => {
storageStates.delete(key);
throw error;
});
storageStates.set(key, creation);
}
return storageStates.get(key)!;
}
function getAdminStorageState(browser: Browser): Promise<SessionState> {
const { email, password } = adminCredentials();
return cachedStorageState(`admin:${email}`, async () => {
const context = await browser.newContext();
try {
const page = await context.newPage();
await loginThroughForm(page, {
visitPath: '/admin/staff/login',
apiPath: '/api/guest/staff/login',
credentials: { email, password },
expectedPath: /^\/admin\/?$/,
});
const probe = await context.request.get('/admin', { maxRedirects: 0 });
expect(probe.status(), 'admin session validation').toBe(200);
return await context.storageState();
} finally {
await context.close();
}
});
}
function getClientStorageState(browser: Browser, client: Credentials): Promise<SessionState> {
return cachedStorageState(`client:${client.email}:${client.password}`, async () => {
const context = await browser.newContext();
try {
const page = await context.newPage();
await loginThroughForm(page, {
visitPath: '/login',
apiPath: '/api/guest/client/login',
credentials: client,
expectedPath: /^\/$/,
});
const probe = await authenticatedGet(context, '/api/client/profile/get');
expect(probe.status(), 'client session validation').toBe(200);
expect((await probe.json()).result?.email, 'client session validation').toBe(client.email);
return await context.storageState();
} finally {
await context.close();
}
});
}
interface Fixtures {
incognitoPage: Page;
adminContext: BrowserContext;
adminPage: Page;
testClient: TestClient;
clientContext: BrowserContext;
clientPage: Page;
}
export const test = base.extend<Fixtures>({
incognitoPage: async ({ browser }, use) => {
const context = await browser.newContext();
const page = await context.newPage();
await use(page);
await context.close();
},
adminContext: async ({ browser }, use) => {
const storageState = await getAdminStorageState(browser);
const context = await browser.newContext({ storageState });
await use(context);
await context.close();
},
adminPage: async ({ adminContext }, use) => {
const page = await adminContext.newPage();
await page.goto('/admin');
await expect(page).toHaveURL((url) => /^\/admin\/?$/.test(new URL(url).pathname), { timeout: 10_000 });
await use(page);
await page.close();
},
testClient: async ({ request }, use) => {
await use(await createTestClient(request));
},
clientContext: async ({ browser, testClient }, use) => {
const storageState = await getClientStorageState(browser, testClient);
const context = await browser.newContext({ storageState });
await use(context);
await context.close();
},
clientPage: async ({ clientContext, testClient }, use) => {
const page = await clientContext.newPage();
await page.goto('/');
await expect(page.locator('body')).toContainText(testClient.email);
await use(page);
await page.close();
},
});
/**
* Opens an independent logged-in client session for a client that is not the shared
* fixture client (e.g. after its password has been changed).
*/
export async function openClientSession(browser: Browser, client: Credentials): Promise<BrowserContext> {
const storageState = await getClientStorageState(browser, client);
const context = await browser.newContext({ storageState });
const page = await context.newPage();
await page.goto('/');
await expect(page.locator('body')).toContainText(client.email);
return context;
}
export { expect };
@@ -0,0 +1,48 @@
import type { APIRequestContext } from '@playwright/test';
export interface TestClient {
id: number;
first_name: string;
last_name: string;
email: string;
password: string;
}
export const defaultClientPassword = 'PlaywrightClient1!';
export function uniqueSuffix(): string {
const random = Math.floor(100000 + Math.random() * 900000);
return `${Date.now()}-${random}`;
}
export async function createTestClient(request: APIRequestContext): Promise<TestClient> {
const suffix = uniqueSuffix();
const client = {
first_name: 'Playwright',
last_name: 'Client',
email: `playwright-client-${suffix}@example.com`,
password: defaultClientPassword,
};
const response = await request.post('/api/guest/client/create', {
data: {
...client,
password_confirm: client.password,
},
});
if (!response.ok()) {
throw new Error(`Client creation failed with HTTP ${response.status()}: ${await response.text()}`);
}
const body = await response.json();
if (body.error !== null || !body.result) {
throw new Error(`Client creation returned an unexpected response: ${JSON.stringify(body)}`);
}
return {
...client,
id: body.result,
};
}
+162
View File
@@ -0,0 +1,162 @@
import { expect, type Locator, type Page, type Response } from '@playwright/test';
/**
* Waits for the next POST response whose URL path contains the given API path fragment.
* Register the returned promise before triggering the action that fires the request.
*/
export function waitForApiResponse(page: Page, apiPathFragment: string): Promise<Response> {
return page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
new URL(response.url()).pathname.includes(apiPathFragment),
);
}
/**
* Programmatically submits a form while still firing its submit event listeners.
*/
export async function submitForm(form: Locator): Promise<void> {
await form.evaluate((element) => (element as HTMLFormElement).requestSubmit());
}
/**
* Fills an input and verifies the final value, honoring any maxlength truncation.
*/
export async function typeAndVerify(input: Locator, value: string): Promise<void> {
await expect(input).toBeVisible();
await expect(input).toBeEnabled();
await input.fill(value);
const maxLengthAttribute = await input.getAttribute('maxlength');
const maxLength = maxLengthAttribute === null ? -1 : Number.parseInt(maxLengthAttribute, 10);
const expectedValue = Number.isInteger(maxLength) && maxLength >= 0 ? value.slice(0, maxLength) : value;
await expect(input).toHaveValue(expectedValue);
}
/**
* Opens a Bootstrap modal by clicking its trigger and waits until it is fully shown.
*/
export async function openBootstrapModal(trigger: Locator, modal: Locator): Promise<void> {
await trigger.click();
await expect(modal).toBeVisible();
await expect(modal).toHaveClass(/\bshow\b/);
}
/**
* Fills a FOSSBilling rich text editor by typing into the visible CKEditor editable
* area; the core runtime syncs typed content back to the underlying textarea.
*/
export async function setEditorContent(scope: Locator, content: string): Promise<void> {
const editor = scope.locator('.ck-content[contenteditable="true"]').first();
await expect(editor).toBeVisible();
await editor.click();
await editor.press('ControlOrMeta+a');
await editor.pressSequentially(content);
}
export interface SignupClient {
first_name: string;
last_name: string;
email: string;
password: string;
}
/**
* Heuristically fills the client signup form: known fields get their values,
* any remaining empty required inputs are backfilled, selects are resolved,
* and required checkboxes are checked.
*/
export async function fillClientSignupForm(page: Page, client: SignupClient): Promise<void> {
const values: Record<string, string> = {
company: 'FOSSBilling Test Company',
birthday: '1990-01-01',
address_1: '1 Playwright Street',
address_2: 'Suite 2',
city: 'Test City',
state: 'Test State',
postcode: '12345',
phone_cc: '1',
phone: '5551234567',
...client,
password_confirm: client.password,
};
const form = page.locator('form[action*="/api/guest/client/create"]');
for (const [name, value] of Object.entries(values)) {
const field = form.locator(`[name="${name}"]`).first();
if ((await field.count()) === 0) {
continue;
}
const fieldType = ((await field.getAttribute('type')) ?? '').toLowerCase();
if (fieldType === 'hidden') {
continue;
}
await field.fill(value);
}
// Backfill any remaining empty required text/textarea inputs.
const requiredFields = form.locator('input[required], textarea[required]');
const requiredCount = await requiredFields.count();
for (let index = 0; index < requiredCount; index++) {
const field = requiredFields.nth(index);
const name = await field.getAttribute('name');
const type = ((await field.getAttribute('type')) ?? '').toLowerCase();
if (!name || Object.prototype.hasOwnProperty.call(values, name)) {
continue;
}
if (['checkbox', 'radio', 'hidden', 'password'].includes(type)) {
continue;
}
if (!(await field.inputValue())) {
await field.fill('Playwright test value');
}
}
const gender = form.locator('select[name="gender"]').first();
if ((await gender.count()) > 0) {
await gender.selectOption('other');
}
const requiredSelects = form.locator('select[required]');
const selectCount = await requiredSelects.count();
for (let index = 0; index < selectCount; index++) {
const select = requiredSelects.nth(index);
if (!(await select.inputValue())) {
const firstOption = select.locator('option[value!=""]').first();
const optionValue = await firstOption.getAttribute('value');
if (optionValue) {
await select.selectOption(optionValue);
}
}
}
const country = form.locator('select[name="country"]').first();
if ((await country.count()) > 0 && !(await country.inputValue())) {
const firstCountry = await country.locator('option[value!=""]').first().getAttribute('value');
if (firstCountry) {
await country.selectOption(firstCountry);
}
}
const checkboxes = form.locator('input[type="checkbox"][required]');
const checkboxCount = await checkboxes.count();
for (let index = 0; index < checkboxCount; index++) {
await checkboxes.nth(index).setChecked(true, { force: true });
}
}
@@ -0,0 +1,29 @@
import { adminCredentials, authenticatedGet, expect, test } from '../../fixtures/e2e';
test('redirects logged-out admin pages to the staff login', async ({ incognitoPage }) => {
await incognitoPage.goto('/admin/client');
await expect(incognitoPage).toHaveURL(
(url) => /^\/admin\/staff\/login\/?$/.test(new URL(url).pathname),
);
await expect(incognitoPage.locator('body')).toContainText('Login');
});
test('allows authenticated admin profile API requests', async ({ adminContext }) => {
const response = await authenticatedGet(adminContext, '/api/admin/profile/get');
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.error).toBeNull();
expect(body.result.id, 'admin id').toBeTruthy();
expect(body.result.email, 'admin email').toBe(adminCredentials().email);
});
test('rejects admin API POST requests without a CSRF token', async ({ adminContext }) => {
const response = await adminContext.request.post('/api/admin/profile/get');
expect(response.status()).toBe(403);
const body = await response.json();
expect(body.result).toBeNull();
expect(body.error.message).toBe('CSRF token invalid');
});
@@ -0,0 +1,6 @@
import { expect, test } from '../../fixtures/e2e';
test('logs in as the installed administrator', async ({ adminPage }) => {
await expect(adminPage).toHaveTitle(/Dashboard/);
await expect(adminPage.locator('body')).toContainText('Clients');
});
@@ -0,0 +1,19 @@
import { expect, test } from '../../fixtures/e2e';
const pages = [
'/admin',
'/admin/client',
'/admin/order',
'/admin/invoice',
'/admin/product',
'/admin/system',
'/admin/extension',
];
test('loads core admin pages successfully', async ({ adminPage }) => {
for (const path of pages) {
await adminPage.goto(path);
await expect(adminPage).toHaveURL((url) => new URL(url).pathname.replace(/\/$/, '') === path);
await expect(adminPage.locator('body')).toBeVisible();
}
});
@@ -0,0 +1,22 @@
import { expect, test } from '../../fixtures/e2e';
const protectedPages = [
'/client',
'/client/profile',
'/order/service',
'/invoice',
'/support',
'/email',
];
for (const path of protectedPages) {
test(`redirects logged-out requests for ${path} to the client login`, async ({ incognitoPage, request }) => {
const response = await request.get(path, { maxRedirects: 0 });
expect(response.status()).toBe(302);
expect(response.headers()['location']).toMatch(/\/login\/?$/);
await incognitoPage.goto(path);
await expect(incognitoPage).toHaveURL((url) => /^\/login\/?$/.test(new URL(url).pathname));
await expect(incognitoPage.locator('body')).toContainText('Login to Your Account');
});
}
@@ -0,0 +1,34 @@
import { authenticatedGet, expect, test } from '../../fixtures/e2e';
import { defaultClientPassword, uniqueSuffix } from '../../helpers/client-factory';
import { fillClientSignupForm, submitForm, waitForApiResponse } from '../../helpers/forms';
test('creates a new client account and automatically logs in', async ({ incognitoPage }) => {
const client = {
first_name: 'Playwright',
last_name: 'Signup',
email: `playwright-signup-${uniqueSuffix()}@example.com`,
password: defaultClientPassword,
};
await incognitoPage.goto('/signup');
await expect(incognitoPage.locator('body')).toContainText('Create a new account');
const signupResponse = waitForApiResponse(incognitoPage, '/api/guest/client/create');
await fillClientSignupForm(incognitoPage, client);
await submitForm(incognitoPage.locator('form[action*="/api/guest/client/create"]'));
const response = await signupResponse;
// The response body cannot be read after the auto-login redirect navigates away;
// the profile request below proves both account creation and the logged-in session.
expect(response.status()).toBe(200);
await expect(incognitoPage).toHaveURL((url) => new URL(url).pathname === '/', { timeout: 10_000 });
const profileResponse = await authenticatedGet(incognitoPage.context(), '/api/client/profile/get');
expect(profileResponse.status()).toBe(200);
const profile = await profileResponse.json();
expect(profile.error).toBeNull();
expect(profile.result.email).toBe(client.email);
});
@@ -0,0 +1,83 @@
import { openClientSession, expect, test } from '../../fixtures/e2e';
import { submitForm, waitForApiResponse } from '../../helpers/forms';
test('updates profile details', async ({ clientPage }) => {
const updatedProfile = {
firstName: 'Updated',
lastName: 'Profile',
company: 'Updated Playwright Company',
country: 'GB',
phoneCountryCode: '44',
phone: '7700900123',
// The stored number is raw, but the input renders it formatted.
phoneDisplay: '7700 900123',
address: '42 Updated Road',
city: 'Updated City',
state: 'Updated State',
postcode: 'UP123',
};
await clientPage.goto('/client/profile');
await expect(clientPage.locator('body')).toContainText('Update Details');
const form = clientPage.locator('form#profile-update');
await form.locator('input[name="first_name"]').fill(updatedProfile.firstName);
await form.locator('input[name="last_name"]').fill(updatedProfile.lastName);
await form.locator('input[name="company"]').fill(updatedProfile.company);
await form.locator('select[name="country"]').selectOption(updatedProfile.country);
await form.locator('input[name="phone"]').fill(updatedProfile.phone);
await form.locator('input[name="address_1"]').fill(updatedProfile.address);
await form.locator('input[name="city"]').fill(updatedProfile.city);
await form.locator('input[name="state"]').fill(updatedProfile.state);
await form.locator('input[name="postcode"]').fill(updatedProfile.postcode);
const profileUpdate = waitForApiResponse(clientPage, '/api/client/profile/update');
await submitForm(form);
// The reloaded form values below prove the update was persisted server-side.
expect((await profileUpdate).status()).toBe(200);
await clientPage.reload();
await expect(form.locator('input[name="first_name"]')).toHaveValue(updatedProfile.firstName);
await expect(form.locator('input[name="last_name"]')).toHaveValue(updatedProfile.lastName);
await expect(form.locator('input[name="company"]')).toHaveValue(updatedProfile.company);
await expect(form.locator('select[name="country"]')).toHaveValue(updatedProfile.country);
await expect(form.locator('input[name="phone_cc"]')).toHaveValue(updatedProfile.phoneCountryCode);
await expect(form.locator('input[name="phone"]')).toHaveValue(updatedProfile.phoneDisplay);
await expect(form.locator('input[name="address_1"]')).toHaveValue(updatedProfile.address);
await expect(form.locator('input[name="city"]')).toHaveValue(updatedProfile.city);
await expect(form.locator('input[name="state"]')).toHaveValue(updatedProfile.state);
await expect(form.locator('input[name="postcode"]')).toHaveValue(updatedProfile.postcode);
});
test('changes the client password', async ({ browser, clientPage, testClient }) => {
const oldPassword = testClient.password;
const newPassword = 'PlaywrightClient2!';
await clientPage.goto('/client/profile');
await clientPage.locator('#pass-tab').click();
const paneForm = clientPage.locator('#pass-tab-pane form');
await paneForm.locator('input[name="current_password"]').fill(oldPassword);
await paneForm.locator('input[name="new_password"]').fill(newPassword);
await paneForm.locator('input[name="confirm_password"]').fill(newPassword);
const passwordChange = waitForApiResponse(clientPage, '/api/client/profile/change_password');
await submitForm(paneForm);
// The stale-login and fresh-login checks below prove the password change server-side.
expect((await passwordChange).status()).toBe(200);
const staleLogin = await clientPage.context().request.post('/api/guest/client/login', {
data: {
email: testClient.email,
password: oldPassword,
},
});
const staleLoginBody = await staleLogin.json();
expect(staleLoginBody.result).toBeNull();
expect(staleLoginBody.error.message).toBe('Please check your login details.');
const context = await openClientSession(browser, { ...testClient, password: newPassword });
await context.close();
});
@@ -0,0 +1,21 @@
import { expect, test } from '../../fixtures/e2e';
const pages = [
{ path: '/', text: 'Dashboard' },
{ path: '/client/profile', text: 'Update Details' },
{ path: '/order/service', text: 'Services' },
{ path: '/invoice', text: 'Invoices' },
{ path: '/support', text: 'Support Tickets' },
{ path: '/email', text: 'Emails' },
];
test('loads core client pages successfully', async ({ clientContext, clientPage }) => {
for (const { path, text } of pages) {
const response = await clientContext.request.get(path);
expect(response.status()).toBe(200);
await clientPage.goto(path);
await expect(clientPage.locator('body')).toBeVisible();
await expect(clientPage.locator('body')).toContainText(text);
}
});
@@ -0,0 +1,69 @@
import { authenticatedGet, expect, test } from '../../fixtures/e2e';
import { uniqueSuffix } from '../../helpers/client-factory';
import {
openBootstrapModal,
setEditorContent,
submitForm,
typeAndVerify,
waitForApiResponse,
} from '../../helpers/forms';
test('opens, replies to, and closes a support ticket', async ({ clientPage }) => {
const suffix = uniqueSuffix();
const subject = `Playwright support lifecycle ${suffix}`;
const initialMessage = `Initial support request from Playwright ${suffix}.`;
const replyMessage = `Follow-up support reply from Playwright ${suffix}.`;
await clientPage.goto('/support');
await expect(clientPage.locator('body')).toContainText('Support Tickets');
// Open the ticket creation modal and submit a new ticket.
const ticketCreate = waitForApiResponse(clientPage, '/api/client/support/ticket_create');
const modal = clientPage.locator('#open-ticket-modal');
await openBootstrapModal(clientPage.locator('[data-bs-target="#open-ticket-modal"]'), modal);
await expect(modal.locator('select[name="support_helpdesk_id"]')).not.toHaveValue('');
await setEditorContent(modal, initialMessage);
await typeAndVerify(modal.locator('input[name="subject"]'), subject);
await submitForm(modal.locator('#ticket-submit'));
expect((await ticketCreate).status()).toBe(200);
// The app redirects to /support/ticket/{id}; the final API check below proves
// every persisted detail of this exchange.
const ticketPathPattern = /^\/support\/ticket\/(\d+)$/;
await expect
.poll(() => new URL(clientPage.url()).pathname.match(ticketPathPattern)?.[1] ?? '', { timeout: 10_000 })
.not.toBe('');
const ticketId = new URL(clientPage.url()).pathname.match(ticketPathPattern)![1];
await expect(clientPage.locator('body')).toContainText(subject);
await expect(clientPage.locator('article.markdown-body')).toContainText(initialMessage);
// Reply to the ticket.
const ticketReply = waitForApiResponse(clientPage, '/api/client/support/ticket_reply');
await setEditorContent(clientPage.locator('#reply-to'), replyMessage);
await submitForm(clientPage.locator('#ticket-reply-form'));
expect((await ticketReply).status()).toBe(200);
await expect(clientPage.locator('article.markdown-body').last()).toContainText(replyMessage);
// Close the ticket.
const ticketClose = waitForApiResponse(clientPage, '/api/client/support/ticket_close');
await clientPage.getByRole('button', { name: 'Close Ticket' }).click();
expect((await ticketClose).status()).toBe(200);
await expect(clientPage.getByRole('button', { name: 'Close Ticket' })).toHaveCount(0);
// Verify the final state through the API.
const finalTicket = await authenticatedGet(clientPage.context(), '/api/client/support/ticket_get', {
id: ticketId,
});
expect(finalTicket.status()).toBe(200);
const finalTicketBody = await finalTicket.json();
expect(finalTicketBody.error).toBeNull();
expect(finalTicketBody.result.status).toBe('closed');
expect(finalTicketBody.result.subject).toBe(subject);
expect(finalTicketBody.result.messages).toHaveLength(2);
});
@@ -0,0 +1,22 @@
import { expect, test } from '../../fixtures/e2e';
const pages = [
{ path: '/', text: null },
{ path: '/login', text: 'Login to Your Account' },
{ path: '/signup', text: 'Create a new account' },
{ path: '/password-reset', text: 'Reset Your Password' },
{ path: '/order', text: null },
];
for (const { path, text } of pages) {
test(`loads ${path}`, async ({ incognitoPage }) => {
await incognitoPage.goto(path);
const body = incognitoPage.locator('body');
await expect(body).toBeVisible();
if (text) {
await expect(body).toContainText(text);
}
});
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["**/*.ts"]
}