diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c330757..5407a3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: persist-credentials: false - name: ShellCheck - run: shellcheck -x git-extensions.sh git-release-style.sh git-release-langpack.sh lib/github-release.sh lib/release-source.sh tests/test-release-scripts.sh tests/test-release-api.sh phpbb-test-board/setup-board.sh phpbb-test-board/run-unit-tests.sh phpbb-test-board/php-lint.sh tests/test-phpbb-test-board.sh tests/test-pr-wait.sh + run: shellcheck -x git-extensions.sh git-release-style.sh git-release-langpack.sh lib/github-release.sh lib/release-source.sh tests/test-release-scripts.sh tests/test-release-api.sh phpbb-test-board/setup-board.sh phpbb-test-board/run-unit-tests.sh phpbb-test-board/php-lint.sh phpbb-test-board/db-matrix.sh tests/test-phpbb-test-board.sh tests/test-pr-wait.sh - name: Tests run: tests/test-release-scripts.sh diff --git a/phpbb-test-board/README.md b/phpbb-test-board/README.md index 8d94ffc..8920c57 100644 --- a/phpbb-test-board/README.md +++ b/phpbb-test-board/README.md @@ -91,6 +91,20 @@ phpbb-test-board/feature_checks.py path/to/extension[@REF] [more extensions...] Exercises the main feature of each phpbbmodders extension that has a check. One example: a moderator can't warn a user in a group that isn't ticked. `--help` lists the extensions with checks. To add one, write a `check_()` function and add it to the `CHECKS` table, keyed by the extension's composer name. +**On another database:** `--seed-board BOARD_ROOT --port PORT` runs the same checks against a board that [seed-forum](https://github.com/phpbbmodders/seed-forum) built, on whichever database it uses: SQLite, MySQL, MariaDB, PostgreSQL or MSSQL. The extensions must already be enabled there, and `--port` is the board's own `server_port`. Nothing is installed or reset. After its check, each extension is disabled, purged and enabled again through phpBB's CLI, which runs its migrations backwards and forwards on that database. SQL and PHP go through seed-forum's `bin/board-sql.php` and `bin/board-php.py`; `--seed-forum DIR` points at the checkout (default: `seed-forum` next to this repository). A check should build route URLs with `b.route("path")`, which gives `app.php/path` on phpBB 3.3 and `index.php/path` on 4.0, and compare numbers from `b.sql()` as strings, since most database drivers return them that way. + +### `db-matrix.sh`: every phpBB branch and database + +```bash +phpbb-test-board/db-matrix.sh path/to/extension [-b 3.3,master] [-d sqlite,mysql,mariadb,postgres,mssql] +``` + +For each phpBB branch and database, builds a seed-forum board with the extension enabled (bind-mounted from the checkout as it is on disk), runs `feature_checks.py --seed-board` against it, and stops that board's database container. It prints a line per board and a summary. Boards and logs go in `--work-dir` (default `~/.cache/phpbb-release-tools/db-matrix`) and are rebuilt on every run; ports start at `--base-port` (default 8130). Oracle isn't covered: phpBB is dropping it. + +The MSSQL container accepts Microsoft's SQL Server Developer licence; leave `mssql` out of `-d` if that hasn't been agreed. Needs Docker and a seed-forum checkout with `board.database` support. + +Exit status: 0 when every board passed, 1 when one failed, 2 for bad arguments. + ### `screenshots.py`: documentation screenshots ```bash diff --git a/phpbb-test-board/_board.py b/phpbb-test-board/_board.py index 56a3a85..3993501 100644 --- a/phpbb-test-board/_board.py +++ b/phpbb-test-board/_board.py @@ -9,6 +9,7 @@ import os import re import shutil +import socket import sqlite3 import subprocess import time @@ -238,6 +239,119 @@ def add_user(self, name: str, password: str) -> int: "'user_type'=>USER_NORMAL,'user_regdate'=>time()]);") return int(out.strip().splitlines()[-1]) + def route(self, path: str) -> str: + """Return the board-relative URL of an extension route (app.php/PATH on phpBB 3.3).""" + return f"app.php/{path}" + + +class SeedBoard(Board): + """An installed seed-forum board, on any database seed-forum supports. + + seed-forum builds and seeds the board, with its extensions enabled; this + only drives it. PHP, SQL and phpBB's CLI run through seed-forum's + bin/board-php.py (host PHP, or a driver container for MSSQL) and + bin/board-sql.php (phpBB's own database layer), so the same checks work on + SQLite, MySQL, MariaDB, PostgreSQL and MSSQL. The admin login is + seed-forum's fixture admin. Nothing is installed or reset here: rebuild the + board with seed-forum between runs that change it. + """ + + ADMIN_USER = "admin" + ADMIN_PASSWORD = "KbTest1234!" + + def __init__(self, root: str, port: int, seed_forum: str) -> None: + self.root = Path(root).expanduser().resolve() + if not (self.root / "config.php").is_file(): + raise SystemExit(f"error: no installed board at {self.root}; build it with seed-forum first") + self.seed_forum = Path(seed_forum).expanduser().resolve() + if not (self.seed_forum / "bin/board-php.py").is_file(): + raise SystemExit(f"error: {self.seed_forum} has no bin/board-php.py; pass a seed-forum checkout " + "with board.database support") + self.admin_user = self.ADMIN_USER + self.admin_password = self.ADMIN_PASSWORD + self.port = port + self.base = f"http://localhost:{port}" + # Inside the board, so a driver container can write it too. + self.php_log = self.root / ".seed-forum-php-errors.log" + self.installed = [] + constants = (self.root / "includes/constants.php").read_text() + version = re.search(r"define\('PHPBB_VERSION', '([^']+)'\)", constants) + self.version = version.group(1) if version else "" + + def _php(self, *args: str, check: bool = True) -> subprocess.CompletedProcess: + """Run PHP for this board through seed-forum's board-php.py.""" + r = subprocess.run(["python3", str(self.seed_forum / "bin/board-php.py"), str(self.root), *args], + cwd=self.root, capture_output=True, text=True, timeout=600) + if check and r.returncode != 0: + raise RuntimeError(f"PHP failed: {(r.stderr or r.stdout).strip()[-400:]}") + return r + + def cli(self, *args: str) -> str: + """Run phpBB's CLI and return its combined output.""" + r = self._php("bin/phpbbcli.php", *args, "--no-interaction", check=False) + return r.stdout + r.stderr + + def purge_cache(self) -> None: + """Purge the board's cache through phpBB's CLI.""" + out = self.cli("cache:purge") + if "Purged" not in out and "purged" not in out: + raise RuntimeError(f"cache purge failed: {out.strip()[-300:]}") + + def sql(self, query: str, args: tuple = ()) -> list: + """Run one SQL statement through phpBB's database layer; return rows as tuples. + + Values come back as phpBB's driver returns them, which on most + databases means strings, so compare numbers as strings. + """ + r = self._php(str(self.seed_forum / "bin/board-sql.php"), str(self.root), query, json.dumps(list(args))) + result = json.loads(r.stdout.strip().splitlines()[-1]) + return [tuple(row) for row in result] if isinstance(result, list) else [] + + def php(self, code: str, check: bool = False) -> str: + """Run PHP inside the board (common.php and functions_user.php loaded); return its output.""" + boot = ("define('IN_PHPBB',1);$phpbb_root_path='./';$phpEx='php';require 'common.php';" + "require 'includes/functions_user.php';$user->session_begin();$auth->acl($user->data);$user->setup();") + return self._php("-r", boot + code, check=check).stdout + + def reset(self) -> None: + """Not available: rebuild a seed-forum board with seed-forum --reset.""" + raise RuntimeError("a seed-forum board is rebuilt with seed-forum --reset, not by feature checks") + + @contextmanager + def serve(self) -> Iterator[None]: + """Serve the board on its own port, with PHP error logging on. + + Refuses to start when something already answers on the port, so the + checks never run against a leftover server for another board. + """ + with socket.socket() as probe: + if probe.connect_ex(("127.0.0.1", self.port)) == 0: + raise RuntimeError(f"port {self.port} is already in use; stop whatever serves it first") + self.php_log.write_text("") + server = subprocess.Popen(["python3", str(self.seed_forum / "bin/board-php.py"), str(self.root), + "-d", "opcache.enable=0", "-d", "log_errors=1", + "-d", f"error_log={self.php_log}", "-S", f"127.0.0.1:{self.port}", + "-t", str(self.root)], + cwd=self.root, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + for _ in range(300): + try: + requests.head(f"{self.base}/images/index.htm", timeout=2) + break + except requests.ConnectionError: + time.sleep(0.1) + yield + finally: + server.terminate() + try: + server.wait(timeout=20) + except subprocess.TimeoutExpired: + server.kill() + + def route(self, path: str) -> str: + """Return the board-relative URL of an extension route (index.php/PATH on phpBB 4.0).""" + return f"index.php/{path}" if self.version.startswith("4.") else f"app.php/{path}" + # Composer packages that are never phpBB extensions. NOT_EXTENSIONS = re.compile(r"^(php|ext-.*|lib-.*|composer/.*|phpbb/phpbb)$") diff --git a/phpbb-test-board/db-matrix.sh b/phpbb-test-board/db-matrix.sh new file mode 100755 index 0000000..993beb0 --- /dev/null +++ b/phpbb-test-board/db-matrix.sh @@ -0,0 +1,152 @@ +#!/usr/bin/bash +# +# Run extensions' feature checks on every phpBB branch and database seed-forum +# supports: for each branch and database, build a seed-forum board with the +# extensions enabled (bind-mounted from their checkouts, as they are on disk), +# run feature_checks.py --seed-board against it (checks plus a +# disable/purge/enable round trip), then unmount its extensions and stop its +# database container. +# Prints one result line per board and a summary. +# +# Needs a seed-forum checkout with board.database support, Docker, and what +# seed-forum and feature_checks.py need. MSSQL accepts Microsoft's SQL Server +# Developer licence (ACCEPT_EULA=Y) in its container; leave mssql out of -d if +# that hasn't been agreed. Oracle isn't covered: phpBB is dropping it. +# +# Without arguments it shows help. +# Exit status: 0 when every board passed, 1 when one failed, 2 for bad arguments. + +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BRANCHES="3.3,master" +DATABASES="sqlite,mysql,mariadb,postgres,mssql" +WORK_DIR="$HOME/.cache/phpbb-release-tools/db-matrix" +SEED_FORUM="$(cd "$SCRIPT_DIR/../.." && pwd)/seed-forum" +BASE_PORT=8130 +KEEP=0 + +usage() { + cat <<'EOF' +Usage: db-matrix.sh [options] EXTENSION_CHECKOUT... + +Run each extension's feature checks on every phpBB branch and database. + +Options: + -b, --branches LIST phpBB branches, comma-separated (default: 3.3,master) + -d, --databases LIST databases, comma-separated (default: sqlite,mysql,mariadb,postgres,mssql) + -w, --work-dir DIR where boards and their configs go (default: ~/.cache/phpbb-release-tools/db-matrix) + -s, --seed-forum DIR seed-forum checkout (default: seed-forum next to this repository) + -p, --base-port PORT first port for the boards; each board gets the next one (default: 8130) + -k, --keep leave the extensions mounted and the database containers running after the checks + -h, --help show this help and exit + +Boards are kept in the work dir between runs, so phpBB is only downloaded once +per branch and database. Each run rebuilds them from scratch. +EOF +} + +die() { + echo "Error: $*" >&2 + exit 2 +} + +EXTENSIONS=() +while [ $# -gt 0 ]; do + case "$1" in + -b|--branches) BRANCHES="${2:?}"; shift 2 ;; + -d|--databases) DATABASES="${2:?}"; shift 2 ;; + -w|--work-dir) WORK_DIR="${2:?}"; shift 2 ;; + -s|--seed-forum) SEED_FORUM="${2:?}"; shift 2 ;; + -p|--base-port) BASE_PORT="${2:?}"; shift 2 ;; + -k|--keep) KEEP=1; shift ;; + -h|--help) usage; exit 0 ;; + -*) die "unknown option $1 (see --help)" ;; + *) EXTENSIONS+=("$(cd "$1" && pwd)"); shift ;; + esac +done +if [ ${#EXTENSIONS[@]} -eq 0 ]; then + usage + exit 0 +fi +[[ "$BASE_PORT" =~ ^[0-9]+$ ]] || die "--base-port must be a number" +[ -f "$SEED_FORUM/bin/db-container.py" ] || die "$SEED_FORUM has no board.database support (bin/db-container.py)" +for ext in "${EXTENSIONS[@]}"; do + [ -f "$ext/composer.json" ] || die "$ext has no composer.json" +done + +mkdir -p "$WORK_DIR" +IFS=, read -r -a branch_list <<< "$BRANCHES" +IFS=, read -r -a database_list <<< "$DATABASES" + +# One YAML config per board, listing the extensions by a label from their names. +write_config() { + local file="$1" root="$2" branch="$3" database="$4" port="$5" + { + echo "# Written by phpbb-release-tools db-matrix.sh; rewritten on every run." + echo "board:" + echo " root: '$root'" + echo " phpbb_branch: '$branch'" + echo " database: $database" + echo " server_name: localhost" + echo " server_port: '$port'" + echo " seeds:" + echo " - bin/seed-standard-fixtures.php" + echo "styles: {}" + echo "extensions:" + for ext in "${EXTENSIONS[@]}"; do + echo " $(jq -r '.name' "$ext/composer.json" | tr '/' '_'):" + echo " source: '$ext'" + done + } > "$file" +} + +summary=() +failed=0 +port="$BASE_PORT" +for branch in "${branch_list[@]}"; do + for database in "${database_list[@]}"; do + name="${branch//./}-$database" + root="$WORK_DIR/boards/$name" + config="$WORK_DIR/$name.yml" + log="$WORK_DIR/$name.log" + if ss -ltn | grep -q ":$port "; then + echo "== $name: port $port is in use; skipping" >&2 + summary+=("$name: SKIPPED (port $port in use)") + failed=1 + port=$((port + 1)) + continue + fi + mkdir -p "$root" + write_config "$config" "$root" "$branch" "$database" "$port" + echo "== $name: building the board on port $port (log: $log)" + if ! python3 "$SEED_FORUM/bin/seed-forum.py" --config "$config" --reset > "$log" 2>&1; then + echo " board build FAILED; last lines of the log:" + tail -15 "$log" | sed 's/^/ /' + summary+=("$name: BUILD FAILED (see $log)") + failed=1 + else + echo "== $name: running the checks" + if "$SCRIPT_DIR/feature_checks.py" "${EXTENSIONS[@]}" --seed-board "$root" --port "$port" \ + --seed-forum "$SEED_FORUM" 2>&1 | tee -a "$log"; then + summary+=("$name: passed") + else + summary+=("$name: FAILED (see $log)") + failed=1 + fi + fi + if [ "$KEEP" = 0 ]; then + # Unmount the extensions, so deleting the work dir can never reach a checkout + python3 "$SEED_FORUM/bin/seed-forum.py" --config "$config" --unmount >> "$log" 2>&1 \ + || echo " Warning: could not unmount the extensions from $root; see $log" >&2 + if [ "$database" != sqlite ]; then + python3 "$SEED_FORUM/bin/db-container.py" stop "$root" >> "$log" 2>&1 || true + fi + fi + port=$((port + 1)) + done +done + +echo "== Summary" +printf ' %s\n' "${summary[@]}" +exit "$failed" diff --git a/phpbb-test-board/feature_checks.py b/phpbb-test-board/feature_checks.py index 95e234c..02433d0 100755 --- a/phpbb-test-board/feature_checks.py +++ b/phpbb-test-board/feature_checks.py @@ -6,6 +6,9 @@ (created by setup-board.sh), after the --with extensions it requires, serves the board, runs that extension's feature check over HTTP, and restores the board. Checks exist for the extensions in CHECKS below, keyed by composer name; add one there for a new extension. +With --seed-board the same checks run against an installed seed-forum board +instead, on whichever database it uses, followed by a disable/purge/enable +round trip of each extension through phpBB's CLI. Test users get random throwaway passwords that are never printed. Exit status: 0 if every check passed, 1 if any failed, 2 for bad arguments. @@ -27,7 +30,7 @@ import requests # noqa: E402 -from _board import Board, ext_name, parse_spec, required_packages # noqa: E402 +from _board import Board, SeedBoard, ext_name, parse_spec, required_packages # noqa: E402 results: list = [] @@ -429,15 +432,30 @@ def nav_links(html: str) -> tuple: """Return whether the header and footer navigation bars link to the contact page.""" header = re.search(r'(?s)id="nav-main".*?id="nav-breadcrumbs"', html) footer = re.search(r'(?s)id="nav-footer".*', html) - return tuple(bool(m and "app.php/contactadmin" in m.group(0)) for m in (header, footer)) + return tuple(bool(m and re.search(r"(app|index)\.php/contactadmin", m.group(0))) for m in (header, footer)) + contact_url = f"{b.base}/{b.route('contactadmin')}" # A fresh board can't send email, so deliver by private message; with # email delivery the contact page shows an error and the ACP won't save. + # No visual confirmation, so a guest can send the form. b.sql("UPDATE phpbb_config SET config_value = '2' WHERE config_name = 'contactadmin_method'") + b.sql("UPDATE phpbb_config SET config_value = '0' WHERE config_name = 'contactadmin_confirm'") b.purge_cache() ok(nav_links(requests.get(f"{b.base}/index.php").text) == (True, True), "guest sees the contact link in the header and footer navigation") - ok('name="message"' in requests.get(f"{b.base}/app.php/contactadmin").text, "guest contact form loads") + ok('name="message"' in requests.get(contact_url).text, "guest contact form loads") + + # A guest's message is stored as a private message to the administrators + guest = requests.Session() + page = guest.get(contact_url).text + action, fields = form_fields(page, "postform") + before_pms = int(b.sql("SELECT COUNT(msg_id) FROM phpbb_privmsgs")[0][0]) + body = post_back(guest, b.base, contact_url, action, { + **fields, "username": "Jane Example", "email": "jane@example.com", "email_confirm": "jane@example.com", + "contact_subject": "Feature check", "message": "Probe message from the feature check.", "submit": "Submit"}) + ok("sent successfully" in body, "guest message sent through the contact form") + ok(int(b.sql("SELECT COUNT(msg_id) FROM phpbb_privmsgs")[0][0]) == before_pms + 1, + "the message was stored as a private message") if not b.sql("SELECT 1 FROM phpbb_config WHERE config_name = 'contactadmin_header_link'"): print(" [skip] header/footer link settings (not in this version)") @@ -478,7 +496,7 @@ def config(name: str) -> str: ok(nav_links(requests.get(f"{b.base}/index.php").text) == (True, False), "footer link gone, header link kept") save("0", "0") ok(nav_links(requests.get(f"{b.base}/index.php").text) == (False, False), "both links off: neither shown") - ok('name="message"' in requests.get(f"{b.base}/app.php/contactadmin").text, + ok('name="message"' in requests.get(contact_url).text, "both links off: the contact form still loads") after = {r[0]: r[1] for r in b.sql("SELECT config_name, config_value FROM phpbb_config " "WHERE config_name LIKE 'contactadmin_%'")} @@ -505,6 +523,43 @@ def config(name: str) -> str: } +def run_seed_board(args: argparse.Namespace) -> int: + """Run the feature checks, then a disable/purge/enable round trip, on a seed-forum board.""" + board = SeedBoard(args.seed_board, args.port, args.seed_forum) + print(f"== board {board.root} (phpBB {board.version}, {board_database(board)})") + for spec in args.repos: + repo = Path(spec.partition("@")[0]).expanduser().resolve() + ext = json.loads((repo / "composer.json").read_text())["name"] + print(f"== {ext} ({repo.name}, as mounted on the board)") + active = board.sql("SELECT ext_active FROM phpbb_ext WHERE ext_name = ?", (ext,)) + if not active or str(active[0][0]) != "1": + ok(False, f"{ext} is not enabled on the board; enable it with seed-forum") + continue + if ext in CHECKS: + with board.serve(): + CHECKS[ext](board) + errors = board.php_errors() + ok(not errors, "no PHP errors logged" + (f": {errors[0][:200]}" if errors else "")) + else: + print(" [skip] no feature check for this extension") + # The extension's migrations, reverted and run again on this database + for step in ("disable", "purge", "enable"): + out = board.cli(f"extension:{step}", ext) + ok("Successfully" in out, f"extension:{step}" + ("" if "Successfully" in out else f": {out.strip()[-200:]}")) + active = board.sql("SELECT ext_active FROM phpbb_ext WHERE ext_name = ?", (ext,)) + ok(active and str(active[0][0]) == "1", "enabled again after the round trip") + + failed = [label for good, label in results if not good] + print(f"== {len(results) - len(failed)}/{len(results)} checks passed") + return 1 if failed else 0 + + +def board_database(board: SeedBoard) -> str: + """Name the database a seed-forum board runs on.""" + state = board.root / ".seed-forum-database.json" + return json.loads(state.read_text())["engine"] if state.is_file() else "sqlite" + + def main() -> int: ap = argparse.ArgumentParser(description="Exercise phpbbmodders extensions' main features on a local test board.", epilog="Extensions with checks: " + ", ".join(sorted(CHECKS)) @@ -518,9 +573,21 @@ def main() -> int: 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)") + help="port for the temporary web server (default: 8083); with --seed-board, the board's " + "own server_port, so its generated links work") + ap.add_argument("-s", "--seed-board", metavar="BOARD_ROOT", + help="run against an installed seed-forum board instead (any database seed-forum supports): " + "the extensions must already be enabled there; nothing is installed or reset, and after " + "the checks each extension is disabled, purged and enabled again through phpBB's CLI") + ap.add_argument("--seed-forum", metavar="DIR", default=str(Path(__file__).resolve().parents[2] / "seed-forum"), + help="seed-forum checkout used with --seed-board (default: seed-forum next to this repository)") args = ap.parse_args() + if args.seed_board: + if args.needed: + ap.error("--with can't be combined with --seed-board; enable required extensions with seed-forum") + return run_seed_board(args) + try: targets = [parse_spec(spec) for spec in args.repos] needed = [(ext_name(r, ref), r, ref) for r, ref in (parse_spec(spec) for spec in args.needed)] diff --git a/tests/test-phpbb-test-board.sh b/tests/test-phpbb-test-board.sh index 0a9a510..8ce7753 100755 --- a/tests/test-phpbb-test-board.sh +++ b/tests/test-phpbb-test-board.sh @@ -101,6 +101,23 @@ check "rename_upgrade.py with the same name twice says why" \ check "rename_upgrade.py --with a non-git directory exits 2" \ exits_with 2 "$tools/rename_upgrade.py" "$renamed" "$renamed" --with "$tmp" -b "$tmp" +# feature_checks.py --seed-board: runs against a seed-forum board; no board is built here +check "feature_checks.py --help describes --seed-board" \ + output_has "--seed-board BOARD_ROOT" "$tools/feature_checks.py" --help +check "feature_checks.py --seed-board without an installed board exits 1" \ + exits_with 1 "$tools/feature_checks.py" "$tmp/repo" --seed-board "$tmp/no-board" +check "feature_checks.py --seed-board with --with exits 2" \ + exits_with 2 "$tools/feature_checks.py" "$tmp/repo" --seed-board "$tmp" --with "$tmp/repo" + +# db-matrix.sh: option handling only; no board or container is started +check "db-matrix.sh without arguments shows help and exits 0" exits_with 0 "$tools/db-matrix.sh" +check "db-matrix.sh --help shows usage" output_has "Usage: db-matrix.sh" "$tools/db-matrix.sh" --help +check "db-matrix.sh with an unknown option exits 2" exits_with 2 "$tools/db-matrix.sh" --nope "$tmp" +check "db-matrix.sh without board.database support in seed-forum exits 2" \ + exits_with 2 "$tools/db-matrix.sh" --seed-forum "$tmp" "$tmp/repo" +check "db-matrix.sh with a non-numeric --base-port exits 2" \ + exits_with 2 "$tools/db-matrix.sh" --base-port abc "$tmp/repo" + # template_a11y.py: icon-only links and buttons need a tooltip and screen-reader text a11y="$tmp/a11y-ext/styles/prosilver/template/event" mkdir -p "$a11y" "$tmp/a11y-good/styles/all/template"