diff --git a/README.md b/README.md index 227130e..6a9dfed 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/phpbb-test-board/_board.py b/phpbb-test-board/_board.py index 1203bc2..69fee70 100644 --- a/phpbb-test-board/_board.py +++ b/phpbb-test-board/_board.py @@ -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// 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 @@ -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. diff --git a/phpbb-test-board/screenshots.py b/phpbb-test-board/screenshots.py index 385686f..61f5441 100755 --- a/phpbb-test-board/screenshots.py +++ b/phpbb-test-board/screenshots.py @@ -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. @@ -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" @@ -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.""" @@ -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, } @@ -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, @@ -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() @@ -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__": diff --git a/tests/test-phpbb-test-board.sh b/tests/test-phpbb-test-board.sh index 4b799ec..1eeba1d 100755 --- a/tests/test-phpbb-test-board.sh +++ b/tests/test-phpbb-test-board.sh @@ -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" \