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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +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.
- `screenshots.py` saves PNG screenshots of an extension's or a style's pages for its documentation, using [Playwright](https://playwright.dev/python/) with Chromium on a board named "Example board" (English). An extension is shown in prosilver, with its board and ACP pages; a checkout with a `style.cfg` is installed as a style and made every user's style. The pages are listed per project in its `SHOTS` table. `--seed SCRIPT` fills the board with forums, topics and users first, for example with [seed-forum](https://github.com/phpbbmodders/seed-forum)'s `bin/seed-standard-fixtures.php`. 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
62 changes: 62 additions & 0 deletions phpbb-test-board/_board.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,49 @@ def remove_ext(self, target: Path) -> None:
self.installed = []
self.reset()

# -- styles ------------------------------------------------------------

def install_style(self, repo: Path, ref: str) -> tuple:
"""Copy a style from git into a clean board, install it and make it every user's style.

The style goes in styles/<name in lowercase letters and digits>/ and
is registered directly in the database, the way ACP » Customise »
Styles » Install does, which is only acceptable on a disposable test
board. Returns (style name, installed path); remove it with
remove_style().
"""
self.reset()
cfg = style_cfg(repo, ref)
name = cfg["name"]
directory = re.sub(r"[^a-z0-9]", "", name.lower())
target = self.root / "styles" / directory
shutil.rmtree(target, ignore_errors=True)
target.mkdir(parents=True)
tar = subprocess.run(["git", "-C", str(repo), "archive", ref], capture_output=True, check=True).stdout
subprocess.run(["tar", "-x", "-C", str(target)], input=tar, check=True)

parent_id, parent_tree, bitfield = 0, "", ""
if cfg.get("parent") and cfg["parent"] != name:
rows = self.sql("SELECT style_id, style_path, style_parent_tree, bbcode_bitfield FROM phpbb_styles "
"WHERE style_name = ?", (cfg["parent"],))
if not rows:
raise RuntimeError(f"parent style {cfg['parent']} is not installed on the board")
parent_id, parent_path, grandparents, bitfield = rows[0]
parent_tree = f"{grandparents}/{parent_path}" if grandparents else parent_path
self.sql("INSERT INTO phpbb_styles (style_name, style_copyright, style_active, style_path, bbcode_bitfield, "
"style_parent_id, style_parent_tree) VALUES (?, ?, 1, ?, ?, ?, ?)",
(name, cfg.get("copyright", ""), directory, bitfield or "kNg=", parent_id, parent_tree))
style_id = self.sql("SELECT style_id FROM phpbb_styles WHERE style_path = ?", (directory,))[0][0]
self.sql("UPDATE phpbb_config SET config_value = ? WHERE config_name = 'default_style'", (str(style_id),))
self.sql("UPDATE phpbb_users SET user_style = ?", (style_id,))
self.purge_cache()
return name, target

def remove_style(self, target: Path) -> None:
"""Remove a style installed by install_style() and restore the clean board."""
shutil.rmtree(target, ignore_errors=True)
self.reset()

# -- web server and sessions -------------------------------------------

@contextmanager
Expand Down Expand Up @@ -215,6 +258,25 @@ def required_packages(repo: Path, ref: str) -> list:
return sorted(name for name in require if not NOT_EXTENSIONS.match(name))


def is_style(repo: Path, ref: str) -> bool:
"""Whether a checkout at a git ref is a phpBB style (style.cfg at its root)."""
return subprocess.run(["git", "-C", str(repo), "cat-file", "-e", f"{ref}:style.cfg"],
capture_output=True).returncode == 0


def style_cfg(repo: Path, ref: str) -> dict:
"""Read a style's style.cfg (name = value lines) at a git ref."""
out = subprocess.run(["git", "-C", str(repo), "show", f"{ref}:style.cfg"],
capture_output=True, text=True, check=True).stdout
cfg = {}
for line in out.splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
cfg[key.strip()] = value.strip()
return cfg


def parse_spec(spec: str) -> tuple:
"""Split "PATH[@REF]" into (resolved path, ref); ref defaults to HEAD.

Expand Down
86 changes: 66 additions & 20 deletions phpbb-test-board/screenshots.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
#!/usr/bin/env python3
"""
Take documentation screenshots of a phpbbmodders extension on a local test board.
Take documentation screenshots of a phpbbmodders extension or style 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.
Installs the extension or style 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 with Playwright (Chromium, English). An
extension is shown in prosilver, with its board and ACP pages; a style is
made every user's style and shown on board pages. The pages are listed in
SHOTS below, keyed by an extension's composer name or a style's name from
style.cfg; add an entry for a new project. Restores the board afterwards.

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

Expand All @@ -28,7 +29,7 @@
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
from _board import Board, ext_name, is_style, parse_spec, required_packages, style_cfg # noqa: E402

BOARD_NAME = "Example board"
BOARD_DESCRIPTION = "A phpBB board"
Expand Down Expand Up @@ -102,7 +103,16 @@ def shot(self, name: str, url: str, admin: bool = False, phone: bool = False,
context.close()


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

def busiest_forum_and_topic(board: Board) -> tuple:
"""The forum with the most topics and the topic with the most replies, so pages show real content."""
forum = board.sql("SELECT forum_id FROM phpbb_forums WHERE forum_type = 1 "
"ORDER BY forum_topics_approved DESC, forum_id LIMIT 1")[0][0]
topic = board.sql("SELECT topic_id FROM phpbb_topics "
"ORDER BY topic_posts_approved DESC, topic_id LIMIT 1")[0][0]
return forum, topic


def shots_documentation(board: Board, shoot: Shooter, args: argparse.Namespace) -> None:
"""phpbbmodders/documentation: needs --build, a phpbbdocs-hugo build."""
Expand Down Expand Up @@ -145,8 +155,20 @@ def open_misc_tab(p) -> None:
admin=True, prepare=open_misc_tab, selector="fieldset:has(legend:has-text('Guests'))")


def shots_prominodeux(board: Board, shoot: Shooter, args: argparse.Namespace) -> None:
"""ProMinoDeux style: the main board pages, on desktop and phone. Use --seed for real content."""
forum, topic = busiest_forum_and_topic(board)
shoot.shot("prominodeux-index", "index.php")
shoot.shot("prominodeux-viewforum", f"viewforum.php?f={forum}")
shoot.shot("prominodeux-viewtopic", f"viewtopic.php?t={topic}")
shoot.shot("prominodeux-posting", f"posting.php?mode=reply&t={topic}&sid={{sid}}", admin=True)
shoot.shot("prominodeux-index-phone", "index.php", phone=True)
shoot.shot("prominodeux-viewtopic-phone", f"viewtopic.php?t={topic}", phone=True)


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


Expand All @@ -161,19 +183,24 @@ def ensure_chromium(playwright) -> Browser:


def main() -> int:
ap = argparse.ArgumentParser(description="Take documentation screenshots of a phpbbmodders extension "
ap = argparse.ArgumentParser(description="Take documentation screenshots of a phpbbmodders extension or style "
"on a local test board.",
epilog="Extensions with screenshots: " + ", ".join(sorted(SHOTS))
epilog="Projects 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)")
help="extension or style git checkout, optionally with a git ref to install "
"(default ref: HEAD); a style is recognised by its style.cfg")
ap.add_argument("-o", "--out", required=True, metavar="DIR",
help="directory to save the PNG files in, for example the extension's docs/images")
help="directory to save the PNG files in, for example the project's docs/images")
ap.add_argument("-s", "--seed", metavar="SCRIPT",
help="PHP script that fills the board with forums, topics and users after the install, "
"run as 'php SCRIPT BOARD_ROOT'; for example seed-forum's bin/seed-standard-fixtures.php")
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")
"installed and enabled first; repeat for several, in the order they must be enabled "
"(extensions only)")
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,
Expand All @@ -185,24 +212,40 @@ def main() -> int:
needed = [parse_spec(spec) for spec in args.needed]
except ValueError as e:
ap.error(str(e))
if args.seed and not Path(args.seed).is_file():
ap.error(f"--seed {args.seed} is not a file")
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)
style = is_style(repo, ref)
if style and needed:
ap.error("--with is for extensions; a style needs no other extensions")
name = style_cfg(repo, ref)["name"] if style else 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)
if style:
ext, target = board.install_style(repo, ref)
out = "Successfully installed"
else:
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
if args.seed:
seeded = subprocess.run(["php", "-d", "opcache.enable_cli=0", str(Path(args.seed).resolve()),
str(board.root)], capture_output=True, text=True)
if seeded.returncode != 0:
print(f" [FAIL] seed: {(seeded.stderr or seeded.stdout).strip()[-300:]}")
return 1
print(f" [ok] seeded: {seeded.stdout.strip().splitlines()[-1][:200] if seeded.stdout.strip() else 'done'}")
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()
Expand All @@ -221,7 +264,10 @@ def main() -> int:
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 style:
board.remove_style(target)
else:
board.remove_ext(target)


if __name__ == "__main__":
Expand Down
4 changes: 4 additions & 0 deletions tests/test-phpbb-test-board.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ check "feature_checks.py --help lists the extensions with checks" \
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"
check "screenshots.py --help describes --seed" output_has "--seed SCRIPT" "$tools/screenshots.py" --help
check "screenshots.py --help lists styles with screenshots" output_has "ProMinoDeux" "$tools/screenshots.py" --help
check "screenshots.py with a missing --seed script exits 2" \
exits_with 2 "$tools/screenshots.py" "$tmp/repo" -o "$tmp/out" --seed "$tmp/no-such-seed.php"
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" \
Expand Down
Loading