Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ phpbb-test-board/setup-board.sh -d ~/phpbb-test-board
export PHPBB_TEST_BOARD=~/phpbb-test-board
phpbb-test-board/smoke_test.py path/to/extension --ref origin/main
phpbb-test-board/feature_checks.py path/to/extension@origin/main
phpbb-test-board/screenshots.py path/to/extension@origin/main -o path/to/extension/docs/images
```

For an extension that needs another one, pass the other extension's checkout with `--with` (repeat it for several, in the order they must be enabled):
Expand All @@ -107,6 +108,7 @@ phpbb-test-board/smoke_test.py path/to/sfscompanion --with path/to/stopforumspam
- `setup-board.sh` installs the current phpBB 3.3 release (or `--version`) with no extensions, and keeps a clean copy of the database so every test starts from the same state. The admin password is generated and stored only in `board.env`.
- `smoke_test.py` installs the extension from git, loads board pages as a guest and as the admin, the extension's routes, ACP/MCP/UCP modules and cron tasks, then disables, deletes data and re-enables it. It reports any server error, empty page, phpBB debug notice or PHP error-log entry.
- `feature_checks.py` exercises the main feature of the phpbbmodders extensions listed in its `--help` (for example: a moderator can't warn a user in an unticked group). Add a check for a new extension in its `CHECKS` table.
- `screenshots.py` saves PNG screenshots of an extension's board and ACP pages for its documentation, using [Playwright](https://playwright.dev/python/) with Chromium on a board named "Example board" (prosilver, English). The pages are listed per extension in its `SHOTS` table. The first run downloads Playwright's Chromium. phpbbmodders/documentation also needs `--build DIR`, a phpbbdocs-hugo build to serve.
- The board is restored after each run. Each script has `--help`.
- `--with` extensions are installed and enabled before the extension under test and stay enabled; only the extension under test goes through the disable and delete data round trip. `feature_checks.py` installs a `--with` extension only for the extensions whose `composer.json` requires it. If `composer.json` requires a package you didn't supply, the scripts print a note naming it.

Expand Down
1 change: 1 addition & 0 deletions phpbb-test-board/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
requests>=2.31
PyYAML>=6.0
playwright>=1.63
228 changes: 228 additions & 0 deletions phpbb-test-board/screenshots.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""
Take documentation screenshots of a phpbbmodders extension on a local test board.

Installs the extension from git into a clean copy of the board (created by
setup-board.sh), renames the board to "Example board", serves it, and saves
PNG screenshots of the extension's board pages and ACP pages with Playwright
(Chromium, prosilver, English). The pages for each extension are listed in
SHOTS below, keyed by composer name; add an entry for a new extension.
Restores the board afterwards.

The first run installs Playwright's Chromium into the user's cache.

Exit status: 0 if every screenshot was saved, 1 if any failed, 2 for bad arguments.
"""
import argparse
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Callable

sys.path.insert(0, str(Path(__file__).resolve().parent))
from _venv import ensure_venv # noqa: E402

ensure_venv()

import requests # noqa: E402
from playwright.sync_api import Browser, Error as PlaywrightError, sync_playwright # noqa: E402

from _board import Board, ext_name, parse_spec, required_packages # noqa: E402

BOARD_NAME = "Example board"
BOARD_DESCRIPTION = "A phpBB board"
DESKTOP = {"width": 1280, "height": 900}
PHONE = {"width": 390, "height": 844}
TIMEOUT_MS = 30000


class Shooter:
"""Saves screenshots of board pages as a guest or as the admin."""

def __init__(self, board: Board, browser: Browser, out_dir: Path) -> None:
self.board = board
self.browser = browser
self.out_dir = out_dir
self.saved: list = []
self.failed: list = []
self.cleanup: list = [] # paths to delete once the screenshots are taken
self._admin_sid = ""
self._admin_cookies: list = []
self._admin_agent = ""

def admin_sid(self) -> str:
"""Log the admin in, ACP included, once; return the session id."""
if not self._admin_sid:
session = requests.Session()
self._admin_sid = self.board.login(session, acp=True)
# phpBB checks the browser string against the one that logged in.
self._admin_agent = session.headers["User-Agent"]
self._admin_cookies = [{"name": c.name, "value": c.value, "domain": "localhost", "path": "/"}
for c in session.cookies]
return self._admin_sid

def shot(self, name: str, url: str, admin: bool = False, phone: bool = False,
selector: str = "", prepare: Callable = None) -> None:
"""Save one screenshot as OUT_DIR/NAME.png.

url is relative to the board root. With admin=True the admin's session
is used and {sid} in url is replaced by its session id. selector crops
the image to that element; prepare(page) runs before the capture, for
example to open a tab or wait for results loaded by JavaScript.
"""
if admin:
url = url.replace("{sid}", self.admin_sid())
options = {"viewport": PHONE if phone else DESKTOP, "locale": "en-GB",
"device_scale_factor": 1, "is_mobile": phone, "has_touch": phone}
if admin:
options["user_agent"] = self._admin_agent
context = self.browser.new_context(**options)
if admin:
context.add_cookies(self._admin_cookies)
page = context.new_page()
page.set_default_timeout(TIMEOUT_MS)
target = self.out_dir / f"{name}.png"
try:
response = page.goto(f"{self.board.base}/{url}", wait_until="networkidle")
if response is None or response.status >= 400:
raise RuntimeError(f"HTTP {response.status if response else 'no response'}")
if prepare:
prepare(page)
if selector:
page.locator(selector).first.screenshot(path=str(target))
else:
page.screenshot(path=str(target))
self.saved.append(target)
print(f" [ok] {target.name}")
except (PlaywrightError, RuntimeError) as e:
self.failed.append(name)
print(f" [FAIL] {name}: {str(e).splitlines()[0][:200]}")
finally:
context.close()


# -- screenshots, one function per extension --------------------------------

def shots_documentation(board: Board, shoot: Shooter, args: argparse.Namespace) -> None:
"""phpbbmodders/documentation: needs --build, a phpbbdocs-hugo build."""
if not args.build:
raise SystemExit("error: phpbbmodders/documentation needs --build DIR, a phpbbdocs-hugo build")
path = board.sql("SELECT config_value FROM phpbb_config "
"WHERE config_name = 'phpbbmodders_documentation_docs_path'")[0][0]
build = board.root / path
shutil.rmtree(build, ignore_errors=True)
shutil.copytree(args.build, build)
shoot.cleanup.append(build)
requests.get(f"{board.base}/index.php") # runs the permission sync left pending by the install
board.purge_cache()

page = "app.php/documentation/en/userguide/user_permissions"
shoot.shot("documentation-page", page)
def show_article(p) -> None:
# On a phone the sidebar comes first; show the article instead.
p.locator(".documentation-main").scroll_into_view_if_needed()
shoot.shot("documentation-page-phone", page, phone=True, prepare=show_article)
shoot.shot("documentation-navbar", "index.php", selector="#page-header")

def wait_for_results(p) -> None:
p.wait_for_selector(".doc-search-results li")
shoot.shot("documentation-search", "app.php/documentation-search/en?q=permissions&js=1",
prepare=wait_for_results)

shoot.shot("documentation-acp-settings",
"adm/index.php?i=-phpbbmodders-documentation-acp-main_module&mode=settings&sid={sid}",
admin=True)

guests = board.sql("SELECT group_id FROM phpbb_groups WHERE group_name = 'GUESTS'")[0][0]

def open_misc_tab(p) -> None:
# Advanced permissions, Misc category, where the documentation permissions are listed.
p.locator("a[onclick*='swap_options'], a:has-text('Advanced Permissions')").first.click()
p.locator("li[id^='tab'] a:has-text('Misc'), a:has-text('Misc')").first.click()
shoot.shot("documentation-acp-permissions",
f"adm/index.php?i=acp_permissions&mode=setting_group_global&group_id[0]={guests}&type=u_&sid={{sid}}",
admin=True, prepare=open_misc_tab, selector="fieldset:has(legend:has-text('Guests'))")


SHOTS = {
"phpbbmodders/documentation": shots_documentation,
}


def ensure_chromium(playwright) -> Browser:
"""Launch Chromium, installing Playwright's copy first if it is missing."""
try:
return playwright.chromium.launch()
except PlaywrightError:
print("Installing Playwright's Chromium ...", file=sys.stderr)
subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=True)
return playwright.chromium.launch()


def main() -> int:
ap = argparse.ArgumentParser(description="Take documentation screenshots of a phpbbmodders extension "
"on a local test board.",
epilog="Extensions with screenshots: " + ", ".join(sorted(SHOTS))
+ ". Exit status: 0 all saved, 1 a screenshot failed, 2 bad arguments.")
ap.add_argument("repo", metavar="REPO[@REF]",
help="extension git checkout, optionally with a git ref to install (default ref: HEAD)")
ap.add_argument("-o", "--out", required=True, metavar="DIR",
help="directory to save the PNG files in, for example the extension's docs/images")
ap.add_argument("--build", metavar="DIR",
help="phpbbdocs-hugo build to serve (phpbbmodders/documentation only)")
ap.add_argument("-w", "--with", dest="needed", action="append", default=[], metavar="PATH[@REF]",
help="git checkout of an extension the extension requires (default ref: HEAD); "
"installed and enabled first; repeat for several, in the order they must be enabled")
ap.add_argument("-b", "--board-dir",
help="board directory created by setup-board.sh (default: $PHPBB_TEST_BOARD)")
ap.add_argument("-p", "--port", type=int, default=8083,
help="port for the temporary web server (default: 8083)")
args = ap.parse_args()

try:
repo, ref = parse_spec(args.repo)
needed = [parse_spec(spec) for spec in args.needed]
except ValueError as e:
ap.error(str(e))
if args.build and not (Path(args.build) / "index.html").is_file():
ap.error(f"--build {args.build} is not a built site (no index.html)")
name = ext_name(repo, ref)
if name not in SHOTS:
ap.error(f"no screenshots defined for {name}; add them to SHOTS in {Path(__file__).name}")
out_dir = Path(args.out).expanduser().resolve()
out_dir.mkdir(parents=True, exist_ok=True)

board = Board(args.board_dir, args.port)
for package in required_packages(repo, ref):
if package not in {ext_name(r, dep_ref) for r, dep_ref in needed}:
print(f" [note] {repo.name} requires {package}; if it is a phpBB extension, add --with PATH")
ext, target, out = board.install_ext(repo, ref, needed)
print(f"== {ext} ({repo.name} @ {ref}) -> {out_dir}")
try:
if "Successfully" not in out:
print(f" [FAIL] enable: {out.strip()[-200:]}")
return 1
board.sql("UPDATE phpbb_config SET config_value = ? WHERE config_name = 'sitename'", (BOARD_NAME,))
board.sql("UPDATE phpbb_config SET config_value = ? WHERE config_name = 'site_desc'", (BOARD_DESCRIPTION,))
board.purge_cache()
with board.serve(), sync_playwright() as playwright:
browser = ensure_chromium(playwright)
shooter = Shooter(board, browser, out_dir)
try:
SHOTS[ext](board, shooter, args)
finally:
browser.close()
for path in shooter.cleanup:
shutil.rmtree(path, ignore_errors=True)
errors = board.php_errors()
if errors:
print(f" [FAIL] PHP errors logged: {errors[0][:200]}")
print(f"== {len(shooter.saved)} saved, {len(shooter.failed)} failed")
return 1 if shooter.failed or errors else 0
finally:
board.remove_ext(target)


if __name__ == "__main__":
sys.exit(main())
9 changes: 6 additions & 3 deletions tests/test-phpbb-test-board.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ check "setup-board.sh without --dir exits 2" exits_with 2 "$tools/setup-board.sh
check "setup-board.sh unknown option exits 2" exits_with 2 "$tools/setup-board.sh" --bogus
check "setup-board.sh bad --version exits 2" exits_with 2 "$tools/setup-board.sh" -d "$tmp/board" -v 4.0.0

# smoke_test.py and feature_checks.py
for script in smoke_test.py feature_checks.py; do
# smoke_test.py, feature_checks.py and screenshots.py
for script in smoke_test.py feature_checks.py screenshots.py; do
check "$script --help exits 0" exits_with 0 "$tools/$script" --help
check "$script without arguments exits 2" exits_with 2 "$tools/$script"
check "$script with a non-git directory exits 2" exits_with 2 "$tools/$script" "$tmp" -b "$tmp"
Expand All @@ -68,7 +68,10 @@ check "feature_checks.py with a missing board.env exits 1" \
exits_with 1 "$tools/feature_checks.py" "$tmp/repo" -b "$tmp/no-board"
check "feature_checks.py --help lists the extensions with checks" \
output_has "phpbbmodders/groupwarn" "$tools/feature_checks.py" --help
for script in smoke_test.py feature_checks.py; do
check "screenshots.py --help lists the extensions with screenshots" \
output_has "phpbbmodders/documentation" "$tools/screenshots.py" --help
check "screenshots.py without --out exits 2" exits_with 2 "$tools/screenshots.py" "$tmp/repo"
for script in smoke_test.py feature_checks.py screenshots.py; do
check "$script --help describes --with" output_has "--with PATH[@REF]" "$tools/$script" --help
check "$script --with a non-git directory exits 2" \
exits_with 2 "$tools/$script" "$tmp/repo" --with "$tmp" -b "$tmp"
Expand Down
Loading