Compare commits
11 Commits
claude/vps
...
claude/reu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6adf255dea | ||
|
|
47acc85a0e | ||
|
|
d0f8c1d65c | ||
|
|
fa1df001ea | ||
|
|
d0f4844af8 | ||
|
|
3fb920e343 | ||
|
|
49daffafb6 | ||
|
|
be686bb3e0 | ||
|
|
84e1bfebfd | ||
|
|
e3be036d19 | ||
|
|
b5e54a4bdd |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -5,7 +5,7 @@
|
||||
#
|
||||
# jobs:
|
||||
# ci:
|
||||
# uses: peptide-repo/peptide-e2e/.github/workflows/ci.yml@main
|
||||
# uses: peptiderepo/peptide-e2e/.github/workflows/ci.yml@main
|
||||
# with:
|
||||
# tests: stubs # or wp-framework | brain-monkey | none
|
||||
# has_js: false
|
||||
|
||||
6
.github/workflows/deploy-app.yml
vendored
6
.github/workflows/deploy-app.yml
vendored
@@ -35,7 +35,7 @@ permissions:
|
||||
jobs:
|
||||
deploy-staging:
|
||||
name: Deploy to Staging
|
||||
runs-on: [self-hosted, peptide-vps]
|
||||
runs-on: ubuntu-latest
|
||||
environment: staging
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
name: Smoke tests (staging)
|
||||
needs: deploy-staging
|
||||
if: ${{ inputs.run_smoke }}
|
||||
uses: peptide-repo/peptide-e2e/.github/workflows/smoke.yml@main
|
||||
uses: peptiderepo/peptide-e2e/.github/workflows/smoke.yml@main
|
||||
secrets:
|
||||
STAGING_WP_ADMIN_USER: ${{ secrets.STAGING_WP_ADMIN_USER }}
|
||||
STAGING_WP_ADMIN_PASSWORD: ${{ secrets.STAGING_WP_ADMIN_PASSWORD }}
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
# skipped smoke (run_smoke: false) still allows prod via the if-condition.
|
||||
needs: [deploy-staging, smoke]
|
||||
if: ${{ !cancelled() && needs.deploy-staging.result == 'success' && needs.smoke.result != 'failure' }}
|
||||
runs-on: [self-hosted, peptide-vps]
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
2
.github/workflows/deploy-health.yml
vendored
2
.github/workflows/deploy-health.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const owner = 'peptide-repo';
|
||||
const owner = 'peptiderepo';
|
||||
const repos = [
|
||||
'prautoblogger','peptide-repo-core','peptide-search-ai',
|
||||
'peptide-news-plugin','peptide-reconstitution-calculator','peptide-starter-theme'
|
||||
|
||||
7
.github/workflows/smoke.yml
vendored
7
.github/workflows/smoke.yml
vendored
@@ -32,14 +32,14 @@ on:
|
||||
jobs:
|
||||
smoke:
|
||||
name: Playwright smoke suite
|
||||
runs-on: [self-hosted, peptide-vps]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout peptide-e2e (explicit — workflow_call defaults to caller repo)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: peptide-repo/peptide-e2e
|
||||
repository: peptiderepo/peptide-e2e
|
||||
ref: main
|
||||
|
||||
- name: Set up Node.js
|
||||
@@ -53,8 +53,7 @@ jobs:
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
# OS deps pre-installed on the self-hosted runner host; --with-deps needs sudo which the sandbox blocks
|
||||
run: npx playwright install chromium
|
||||
run: npx playwright install chromium --with-deps
|
||||
|
||||
- name: Run smoke tests
|
||||
env:
|
||||
|
||||
54
CHANGELOG.md
54
CHANGELOG.md
@@ -1,59 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [1.8.0] - 2026-06-23
|
||||
|
||||
### Added
|
||||
- **`infra/vps/` PRAB generation orchestrator**: systemd timer + service + bash
|
||||
script that SSHes from the Coolify KVM8 VPS to Hostinger and drives
|
||||
`wp prautoblogger generate --sync` in a held-open SSH session. Sidesteps
|
||||
Hostinger's ~10-min background-PHP-process kill that was blocking full
|
||||
generation runs. Components:
|
||||
- `orchestrate-generation.sh`: main script with DRY_RUN mode, structured
|
||||
logging, reaper pre-sweep, failure email alert, and shellcheck-clean quoting.
|
||||
- `prab-generation.service` / `prab-generation.timer`: systemd oneshot service
|
||||
(2h timeout, no auto-restart) + daily 06:00 UTC timer with 5-min jitter.
|
||||
- `install-orchestrator.sh`: one-time setup script; installs units but does NOT
|
||||
enable the timer (requires explicit CEO greenlight post-QA).
|
||||
- Security: `StrictHostKeyChecking=accept-new` with pre-seeded known_hosts for
|
||||
the Hostinger IP; no hardcoded secrets; SSH key referenced by env-var path.
|
||||
|
||||
## [1.7.2] - 2026-06-17
|
||||
|
||||
### Fixed
|
||||
- **CI**: Drop `--with-deps` from `npx playwright install chromium` in `smoke.yml`.
|
||||
The `peptide-vps` self-hosted runner has OS-level Playwright deps pre-installed by
|
||||
root; `--with-deps` invokes `apt-get` which requires `sudo`, blocked by the runner's
|
||||
`NoNewPrivileges=true` systemd hardening. Binary-only install works without
|
||||
elevated privileges.## [1.7.1] - 2026-06-17
|
||||
|
||||
### Changed
|
||||
- **CI/CD**: Switch `deploy-staging`, `deploy-production`, and `smoke` jobs from
|
||||
`ubuntu-latest` (GitHub-hosted) to `[self-hosted, peptide-vps]` (org runner on
|
||||
KVM8 VPS). This routes all Hostinger SSH/rsync traffic through our stable VPS IP,
|
||||
fixing the Azure-region throttle that caused intermittent deploy failures.
|
||||
Callers' CI/validate jobs are unaffected (remain on `ubuntu-latest`).
|
||||
|
||||
## [1.7.0] - 2026-06-17
|
||||
|
||||
### Added
|
||||
- **Peptide News admin smoke (07)** (`07-peptide-news-admin.spec.ts`).
|
||||
Covers the Peptide News Plugin admin surfaces: dashboard (HTTP 200, no fatal,
|
||||
h1 "Peptide News Analytics" heading, `.pn-summary-cards`, `.pn-date-filter`),
|
||||
settings (form present), articles, and LLM costs sub-pages. Skip-if-absent guard
|
||||
on all tests using the standard 05/06 merge-early binding pattern. Slugs sourced
|
||||
from `admin/class-pn-admin-menu.php`.
|
||||
|
||||
- **PR Vision admin smoke (08)** (`08-pr-vision-admin.spec.ts`).
|
||||
Covers the PR Vision plugin admin surfaces: dashboard (HTTP 200, no fatal,
|
||||
h1 "PR Vision" visible), Settings page (API-key-manager card `#prv-key-card`
|
||||
present, status badge `#prv-key-source-badge` visible), write-only security
|
||||
invariant (`#prv_api_key` value="" + full HTML must not contain "sk-or-"),
|
||||
Costs sub-page (`pr-vision-costs`), and Call Log sub-page (`pr-vision-calls`).
|
||||
Skip-if-absent at two levels: plugin absent (loud annotation + skip), and
|
||||
`#prv-key-card` absent (Settings v0.2.0 not yet deployed on environment).
|
||||
Selectors sourced from `class-prv-admin-page.php`, `class-prv-key-manager-renderer.php`.
|
||||
|
||||
|
||||
All notable changes to the peptide-e2e smoke suite will be documented here.
|
||||
|
||||
## [1.6.0] - 2026-06-16
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# install-orchestrator.sh — One-time setup for the PRAB generation orchestrator on the VPS.
|
||||
#
|
||||
# Installs:
|
||||
# 1. orchestrate-generation.sh -> /opt/prab-orchestrator/ (from this repo)
|
||||
# 2. prab-generation.service -> /etc/systemd/system/
|
||||
# 3. prab-generation.timer -> /etc/systemd/system/
|
||||
#
|
||||
# Does NOT enable or start the timer — that is an explicit post-QA cutover step.
|
||||
# Does NOT write secrets — the SSH key at /root/.ssh/hostinger_prod_ed25519 must
|
||||
# already be present (placed manually by the CTO from .env.credentials).
|
||||
#
|
||||
# Run as root on the KVM8 VPS (76.13.220.15):
|
||||
# bash infra/vps/install-orchestrator.sh
|
||||
#
|
||||
# After install, validate with DRY_RUN=1 before enabling the timer:
|
||||
# DRY_RUN=1 /opt/prab-orchestrator/orchestrate-generation.sh
|
||||
# systemctl enable --now prab-generation.timer # only after CEO greenlight
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
INSTALL_DIR="/opt/prab-orchestrator"
|
||||
SYSTEMD_DIR="/etc/systemd/system"
|
||||
ORCHESTRATE_SH="${SCRIPT_DIR}/orchestrate-generation.sh"
|
||||
SERVICE_FILE="${SCRIPT_DIR}/prab-generation.service"
|
||||
TIMER_FILE="${SCRIPT_DIR}/prab-generation.timer"
|
||||
|
||||
# Must be root.
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
echo "ERROR: install-orchestrator.sh must be run as root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate source files exist.
|
||||
for f in "${ORCHESTRATE_SH}" "${SERVICE_FILE}" "${TIMER_FILE}"; do
|
||||
if [[ ! -f "$f" ]]; then
|
||||
echo "ERROR: source file not found: $f" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Installing PRAB orchestrator to ${INSTALL_DIR}..."
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
install -m 0750 "${ORCHESTRATE_SH}" "${INSTALL_DIR}/orchestrate-generation.sh"
|
||||
echo " OK: ${INSTALL_DIR}/orchestrate-generation.sh"
|
||||
|
||||
# Pre-seed known_hosts with Hostinger's host key so ssh(1) can verify
|
||||
# the server identity without StrictHostKeyChecking=no.
|
||||
# ssh-keyscan output is pinned here; re-run if Hostinger rotates its key.
|
||||
KNOWN_HOSTS_FILE="${INSTALL_DIR}/known_hosts"
|
||||
if [[ ! -f "${KNOWN_HOSTS_FILE}" ]]; then
|
||||
echo "Pre-seeding known_hosts from Hostinger (${SSH_HOST:-145.223.107.228}:${SSH_PORT:-65002})..."
|
||||
ssh-keyscan -p "${SSH_PORT:-65002}" "${SSH_HOST:-145.223.107.228}" \
|
||||
> "${KNOWN_HOSTS_FILE}" 2>/dev/null \
|
||||
&& chmod 0600 "${KNOWN_HOSTS_FILE}" \
|
||||
&& echo " OK: ${KNOWN_HOSTS_FILE}" \
|
||||
|| echo " WARN: ssh-keyscan failed — known_hosts not seeded; first SSH will add key via accept-new"
|
||||
else
|
||||
echo " OK: ${KNOWN_HOSTS_FILE} (already exists, not overwritten)"
|
||||
fi
|
||||
|
||||
echo "Installing systemd units..."
|
||||
install -m 0644 "${SERVICE_FILE}" "${SYSTEMD_DIR}/prab-generation.service"
|
||||
install -m 0644 "${TIMER_FILE}" "${SYSTEMD_DIR}/prab-generation.timer"
|
||||
echo " OK: ${SYSTEMD_DIR}/prab-generation.service"
|
||||
echo " OK: ${SYSTEMD_DIR}/prab-generation.timer"
|
||||
|
||||
systemctl daemon-reload
|
||||
echo " OK: systemctl daemon-reload"
|
||||
|
||||
echo ""
|
||||
echo "Install complete. Timer is installed but NOT enabled."
|
||||
echo "Next steps (post-QA + CEO greenlight only):"
|
||||
echo " 1. Ensure SSH key exists: ls -la /root/.ssh/hostinger_prod_ed25519"
|
||||
echo " 2. Validate with dry run: DRY_RUN=1 /opt/prab-orchestrator/orchestrate-generation.sh"
|
||||
echo " 3. Run first real invocation: /opt/prab-orchestrator/orchestrate-generation.sh"
|
||||
echo " 4. Enable timer: systemctl enable --now prab-generation.timer"
|
||||
echo " 5. Confirm next run: systemctl list-timers prab-generation.timer"
|
||||
@@ -1,158 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# orchestrate-generation.sh — VPS orchestrator for PRAutoBlogger article generation
|
||||
#
|
||||
# Runs as a systemd oneshot service (prab-generation.service) on the Coolify KVM8 VPS.
|
||||
# SSHes to Hostinger and drives wp prautoblogger generate --sync in a held-open SSH
|
||||
# session. SSH-attached processes survive Hostinger's ~10-min background-PHP-process kill.
|
||||
#
|
||||
# Usage:
|
||||
# DRY_RUN=1 ./orchestrate-generation.sh # echo commands, no generation
|
||||
# ./orchestrate-generation.sh # real run
|
||||
#
|
||||
# Alerting: on failure, sends email to cto@peptiderepo.com via system mail.
|
||||
# Logs: structured log lines to stdout (captured by systemd journal).
|
||||
# Lock: the plugin's Generation_Lock (DB mutex) prevents double-drive.
|
||||
#
|
||||
# Environment variables:
|
||||
# DRY_RUN Set to "1" to echo SSH/WP-CLI commands without executing. Default: 0.
|
||||
# LOGFILE Path for persistent log file. Default: /var/log/prab-generation.log.
|
||||
# ALERT_EMAIL Failure alert recipient. Default: cto@peptiderepo.com.
|
||||
# SSH_KEY Path to Hostinger SSH private key. Default: /root/.ssh/hostinger_prod_ed25519.
|
||||
# SSH_HOST Hostinger SSH host. Default: 145.223.107.228.
|
||||
# SSH_PORT Hostinger SSH port. Default: 65002.
|
||||
# SSH_USER Hostinger SSH user. Default: u117248512.
|
||||
# WP_PATH WordPress docroot on Hostinger. Default: /home/u117248512/domains/peptiderepo.com/public_html.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration (override via environment; no hardcoded secrets)
|
||||
# ---------------------------------------------------------------------------
|
||||
DRY_RUN="${DRY_RUN:-0}"
|
||||
LOGFILE="${LOGFILE:-/var/log/prab-generation.log}"
|
||||
ALERT_EMAIL="${ALERT_EMAIL:-cto@peptiderepo.com}"
|
||||
SSH_KEY="${SSH_KEY:-/root/.ssh/hostinger_prod_ed25519}"
|
||||
SSH_HOST="${SSH_HOST:-145.223.107.228}"
|
||||
SSH_PORT="${SSH_PORT:-65002}"
|
||||
SSH_USER="${SSH_USER:-u117248512}"
|
||||
WP_PATH="${WP_PATH:-/home/u117248512/domains/peptiderepo.com/public_html}"
|
||||
# Known-hosts file pre-seeded by install-orchestrator.sh with Hostinger's host key.
|
||||
# Pins the server identity; StrictHostKeyChecking=accept-new adds on first connect.
|
||||
KNOWN_HOSTS="${KNOWN_HOSTS:-/opt/prab-orchestrator/known_hosts}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
PID="$$"
|
||||
TS() { date -u '+%Y-%m-%dT%H:%M:%SZ'; }
|
||||
|
||||
log() {
|
||||
local msg="prab-generation[${PID}]: $*"
|
||||
echo "$msg"
|
||||
echo "$(TS) $msg" >> "${LOGFILE}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
die() {
|
||||
log "FATAL: $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validate required key before doing anything
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "${DRY_RUN}" != "1" ]] && [[ ! -f "${SSH_KEY}" ]]; then
|
||||
die "SSH key not found at ${SSH_KEY}. Run install-orchestrator.sh first."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DRY_RUN mode: echo commands and exit
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "${DRY_RUN}" == "1" ]]; then
|
||||
log "DRY_RUN=1 — echoing commands only, no generation"
|
||||
log "WOULD RUN: ssh -i ${SSH_KEY} -p ${SSH_PORT} -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15 ${SSH_USER}@${SSH_HOST} \\"
|
||||
log " 'wp prautoblogger generate --sync --path=${WP_PATH} 2>&1'"
|
||||
log "DRY_RUN complete"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Run start
|
||||
# ---------------------------------------------------------------------------
|
||||
START_TS="$(TS)"
|
||||
log "START ts=${START_TS}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 1: Reaper sweep — clear any stuck runs before starting.
|
||||
# Belt-and-suspenders: the plugin's daily reaper cron handles this too, but
|
||||
# running it explicitly here ensures a clean state before each generation.
|
||||
# ---------------------------------------------------------------------------
|
||||
log "REAPER_SWEEP: invoking Run_Reaper::reap()"
|
||||
REAP_CMD="wp eval 'echo json_encode(PRAutoBlogger_Run_Reaper::reap());' --path=${WP_PATH} 2>&1"
|
||||
|
||||
REAP_OUT=""
|
||||
# shellcheck disable=SC2029
|
||||
REAP_OUT="$(ssh -i "${SSH_KEY}" \
|
||||
-p "${SSH_PORT}" \
|
||||
-o StrictHostKeyChecking=accept-new \
|
||||
-o UserKnownHostsFile="${KNOWN_HOSTS}" \
|
||||
-o ConnectTimeout=15 \
|
||||
-o ServerAliveInterval=60 \
|
||||
-o ServerAliveCountMax=30 \
|
||||
"${SSH_USER}@${SSH_HOST}" \
|
||||
"${REAP_CMD}" \
|
||||
2>&1)" || true
|
||||
log "REAPER_RESULT: ${REAP_OUT}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 2: Run wp prautoblogger generate --sync
|
||||
# ---------------------------------------------------------------------------
|
||||
log "SSH_CONNECT: ${SSH_USER}@${SSH_HOST}:${SSH_PORT} key=${SSH_KEY}"
|
||||
|
||||
WP_CMD="wp prautoblogger generate --sync --path=${WP_PATH} 2>&1"
|
||||
|
||||
RESULT_OUTPUT=""
|
||||
EXIT_CODE=0
|
||||
|
||||
# shellcheck disable=SC2029
|
||||
RESULT_OUTPUT="$(ssh -i "${SSH_KEY}" \
|
||||
-p "${SSH_PORT}" \
|
||||
-o StrictHostKeyChecking=accept-new \
|
||||
-o UserKnownHostsFile="${KNOWN_HOSTS}" \
|
||||
-o ConnectTimeout=15 \
|
||||
-o ServerAliveInterval=60 \
|
||||
-o ServerAliveCountMax=30 \
|
||||
"${SSH_USER}@${SSH_HOST}" \
|
||||
"${WP_CMD}" \
|
||||
2>&1)" || EXIT_CODE=$?
|
||||
|
||||
# Extract the last JSON summary line (the one from WP_CLI::log(json_encode(...)))
|
||||
LAST_JSON="$(echo "${RESULT_OUTPUT}" | grep -E '^\{' | tail -1 || true)"
|
||||
LAST_LINE="$(echo "${RESULT_OUTPUT}" | tail -1)"
|
||||
|
||||
log "SSH_RESULT exit=${EXIT_CODE} json=${LAST_JSON:-none}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 3: Evaluate result
|
||||
# ---------------------------------------------------------------------------
|
||||
END_TS="$(TS)"
|
||||
|
||||
if [[ ${EXIT_CODE} -eq 0 ]]; then
|
||||
log "END result=success ts=${END_TS}"
|
||||
else
|
||||
log "END result=failure exit=${EXIT_CODE} last_line=${LAST_LINE} ts=${END_TS}"
|
||||
|
||||
# Send failure alert via email (system mail; no external deps required).
|
||||
# cto@peptiderepo.com forwards to peptiderepo@gmail.com via Cloudflare Email Routing.
|
||||
ALERT_BODY="PRAutoBlogger generation FAILED on $(hostname) at ${END_TS}.
|
||||
Exit code: ${EXIT_CODE}
|
||||
Last output: ${LAST_LINE}
|
||||
Full log: ${LOGFILE}
|
||||
SSH: ${SSH_USER}@${SSH_HOST}:${SSH_PORT}
|
||||
WP path: ${WP_PATH}"
|
||||
|
||||
echo "${ALERT_BODY}" \
|
||||
| mail -s "PRAB generation FAILED @ ${END_TS}" "${ALERT_EMAIL}" 2>/dev/null \
|
||||
|| log "ALERT_FAILED: could not send email to ${ALERT_EMAIL} (mail not configured?)"
|
||||
|
||||
exit "${EXIT_CODE}"
|
||||
fi
|
||||
@@ -1,22 +0,0 @@
|
||||
[Unit]
|
||||
Description=PRAutoBlogger article generation (VPS orchestrator)
|
||||
Documentation=https://github.com/peptide-repo/peptide-e2e/tree/main/infra/vps/
|
||||
After=network.target
|
||||
# Do not start if the timer fires while another instance is still running.
|
||||
# Type=oneshot + TimeoutStartSec enforce this at the systemd level.
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# root owns /root/.ssh/hostinger_prod_ed25519 and /opt/prab-orchestrator/known_hosts;
|
||||
# a dedicated service user would need its own keypair + sudo-less SSH access.
|
||||
# Acceptable on this dedicated lab VPS (not shared); revisit if VPS is multi-tenant.
|
||||
User=root
|
||||
ExecStart=/opt/prab-orchestrator/orchestrate-generation.sh
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=prab-generation
|
||||
# Allow up to 2 hours for a full generation run (orchestrate + draft LLM calls).
|
||||
# The longest observed single LLM call was 1925s (~32 min); normal runs 2-5 min.
|
||||
TimeoutStartSec=7200
|
||||
# Do not restart on failure — the timer will retry the next day.
|
||||
Restart=no
|
||||
@@ -1,19 +0,0 @@
|
||||
[Unit]
|
||||
Description=Daily PRAutoBlogger generation at 06:00 UTC
|
||||
Documentation=https://github.com/peptide-repo/peptide-e2e/tree/main/infra/vps/
|
||||
Requires=prab-generation.service
|
||||
|
||||
[Timer]
|
||||
# Fire daily at 06:00 UTC — before Hostinger's daily wp-cron window.
|
||||
OnCalendar=*-*-* 06:00:00 UTC
|
||||
# Persistent=true: if the VPS was down at 06:00 UTC, fire immediately on next
|
||||
# boot so at most one day's generation is missed (not deferred indefinitely).
|
||||
Persistent=true
|
||||
# Up to 5 min random jitter to avoid thundering-herd if multiple VPS instances
|
||||
# are ever added. Harmless with a single instance.
|
||||
RandomizedDelaySec=300
|
||||
# Accuracy within 1 minute (default is 1 minute; explicit for clarity).
|
||||
AccuracySec=60
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "peptide-e2e",
|
||||
"version": "1.8.0",
|
||||
"version": "1.5.0",
|
||||
"description": "Playwright smoke tests for peptiderepo.com (staging + production)",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* 07-peptide-news-admin.spec.ts
|
||||
*
|
||||
* What: Smoke tests for the Peptide News Plugin admin surfaces.
|
||||
* Verifies the dashboard, settings, articles, and cost sub-pages
|
||||
* load without PHP fatals and checks expected UI elements.
|
||||
* Guards against activation failures, missing menu registration, or
|
||||
* PHP fatals introduced by a bad deploy.
|
||||
*
|
||||
* Who triggers it: Playwright chromium project (post-auth setup).
|
||||
* Dependencies: wp-admin authenticated session (from global.setup.ts).
|
||||
*
|
||||
* SKIP-IF-ABSENT: When the Peptide News Plugin is not installed/activated on
|
||||
* this environment, each test detects the absent menu page and skips with a
|
||||
* loud annotation — consistent with the 05/06 merge-early binding pattern.
|
||||
*
|
||||
* Admin menu slugs (sourced from admin/class-pn-admin-menu.php):
|
||||
* Dashboard -> admin.php?page=peptide-news-dashboard
|
||||
* Settings -> admin.php?page=peptide-news-settings
|
||||
* Articles -> admin.php?page=peptide-news-articles
|
||||
* LLM Costs -> admin.php?page=peptide-news-costs
|
||||
*
|
||||
* Selectors sourced from admin/partials/dashboard.php:
|
||||
* h1 "Peptide News Analytics" - page heading (text match)
|
||||
* .pn-summary-cards - summary card row
|
||||
* .pn-date-filter - date range filter form
|
||||
*
|
||||
* @see peptiderepo/peptide-news-plugin admin/class-pn-admin-menu.php
|
||||
* @see peptiderepo/peptide-news-plugin admin/partials/dashboard.php
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const ADMIN = '/wp-admin';
|
||||
|
||||
/**
|
||||
* Detect the "page not registered" condition WordPress returns when a plugin
|
||||
* menu slug is unknown. Returns true when the plugin is absent.
|
||||
*
|
||||
* WordPress either:
|
||||
* (a) Shows "You do not have sufficient permissions to access this page."
|
||||
* (b) Shows a notice that the page cannot be found
|
||||
* (c) Redirects back to /wp-admin/ stripping the ?page= param
|
||||
*/
|
||||
function isPluginAbsent(body: string, url: string): boolean {
|
||||
return (
|
||||
body.includes('Invalid plugin page') ||
|
||||
body.includes('do not have sufficient permissions') ||
|
||||
body.includes('The page you are looking for') ||
|
||||
( url.includes('/wp-admin/') && ! url.includes('page=peptide-news') )
|
||||
);
|
||||
}
|
||||
|
||||
/** Push a loud skip annotation and call test.skip(). */
|
||||
function skipMissing(): void {
|
||||
test.info().annotations.push({
|
||||
type: 'skip',
|
||||
description:
|
||||
'[peptide-news smoke] Peptide News Plugin is not installed or activated ' +
|
||||
'on this environment. Skipping per the merge-early binding.',
|
||||
});
|
||||
test.skip();
|
||||
}
|
||||
|
||||
test.describe('WP Admin - Peptide News Plugin dashboard', () => {
|
||||
test('dashboard page loads (HTTP 200, no PHP fatal)', async ({ page }) => {
|
||||
const res = await page.goto(`${ADMIN}/admin.php?page=peptide-news-dashboard`);
|
||||
expect(res?.status()).toBe(200);
|
||||
|
||||
await expect(page.locator('#wpbody')).toBeVisible();
|
||||
|
||||
const body = (await page.textContent('body')) ?? '';
|
||||
expect(body).not.toContain('Fatal error');
|
||||
|
||||
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
|
||||
});
|
||||
|
||||
test('dashboard renders analytics heading, summary cards, and date filter', async ({ page }) => {
|
||||
await page.goto(`${ADMIN}/admin.php?page=peptide-news-dashboard`);
|
||||
|
||||
await expect(page.locator('#wpbody')).toBeVisible();
|
||||
const body = (await page.textContent('body')) ?? '';
|
||||
expect(body).not.toContain('Fatal error');
|
||||
|
||||
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
|
||||
|
||||
// Heading from admin/partials/dashboard.php: <h1>Peptide News Analytics</h1>
|
||||
await expect(
|
||||
page.locator('h1').filter({ hasText: 'Peptide News Analytics' })
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Summary card row: <div class="pn-summary-cards">
|
||||
await expect(page.locator('.pn-summary-cards')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Date range filter form: <form class="pn-date-filter">
|
||||
await expect(page.locator('.pn-date-filter')).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('WP Admin - Peptide News Plugin settings page', () => {
|
||||
test('settings page loads (HTTP 200, no fatal, settings form present)', async ({ page }) => {
|
||||
const res = await page.goto(`${ADMIN}/admin.php?page=peptide-news-settings`);
|
||||
expect(res?.status()).toBe(200);
|
||||
|
||||
await expect(page.locator('#wpbody')).toBeVisible();
|
||||
const body = (await page.textContent('body')) ?? '';
|
||||
expect(body).not.toContain('Fatal error');
|
||||
|
||||
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
|
||||
|
||||
// Every settings page rendered by class-pn-admin-settings-page.php contains a form
|
||||
await expect(page.locator('form').first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('WP Admin - Peptide News Plugin articles page', () => {
|
||||
test('articles page loads without fatal error', async ({ page }) => {
|
||||
const res = await page.goto(`${ADMIN}/admin.php?page=peptide-news-articles`);
|
||||
expect(res?.status()).toBe(200);
|
||||
|
||||
await expect(page.locator('#wpbody')).toBeVisible();
|
||||
const body = (await page.textContent('body')) ?? '';
|
||||
expect(body).not.toContain('Fatal error');
|
||||
|
||||
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
|
||||
|
||||
expect(body).not.toContain('Invalid plugin page');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('WP Admin - Peptide News Plugin LLM costs page', () => {
|
||||
test('costs page loads without fatal error', async ({ page }) => {
|
||||
const res = await page.goto(`${ADMIN}/admin.php?page=peptide-news-costs`);
|
||||
expect(res?.status()).toBe(200);
|
||||
|
||||
await expect(page.locator('#wpbody')).toBeVisible();
|
||||
const body = (await page.textContent('body')) ?? '';
|
||||
expect(body).not.toContain('Fatal error');
|
||||
|
||||
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
|
||||
|
||||
expect(body).not.toContain('Invalid plugin page');
|
||||
});
|
||||
});
|
||||
@@ -1,220 +0,0 @@
|
||||
/**
|
||||
* 08-pr-vision-admin.spec.ts
|
||||
*
|
||||
* What: Smoke tests for the PR Vision plugin admin surfaces.
|
||||
* Covers the dashboard, Settings (API-key-manager card + write-only
|
||||
* invariant), Costs, and Call Log sub-pages. Guards against PHP fatals,
|
||||
* missing menu registration, and key leakage regressions.
|
||||
*
|
||||
* Who triggers it: Playwright chromium project (post-auth setup).
|
||||
* Dependencies: wp-admin authenticated session (from global.setup.ts).
|
||||
*
|
||||
* SKIP-IF-ABSENT: When PR Vision is not installed/activated on this
|
||||
* environment, each test detects the absent page and skips with a loud
|
||||
* annotation - consistent with the 05/06 merge-early binding pattern.
|
||||
*
|
||||
* Admin menu slugs (sourced from includes/core/class-prv-admin-page.php
|
||||
* and includes/core/class-prv-settings-page.php,
|
||||
* includes/core/class-prv-costs-page.php,
|
||||
* includes/core/class-prv-call-log-page.php):
|
||||
* Dashboard -> admin.php?page=pr-vision (MENU_SLUG = 'pr-vision')
|
||||
* Settings -> admin.php?page=pr-vision-settings (MENU_SLUG = 'pr-vision-settings')
|
||||
* Costs -> admin.php?page=pr-vision-costs (MENU_SLUG = 'pr-vision-costs')
|
||||
* Call Log -> admin.php?page=pr-vision-calls (MENU_SLUG = 'pr-vision-calls')
|
||||
*
|
||||
* Selectors sourced from includes/core/class-prv-admin-page.php:
|
||||
* h1 "PR Vision - AI Visibility" - dashboard page heading
|
||||
*
|
||||
* Selectors sourced from includes/core/class-prv-key-manager-renderer.php:
|
||||
* #prv-key-card - the API-key-manager card container
|
||||
* #prv-key-source-badge - key source status indicator span
|
||||
* #prv_api_key - password input (must render empty - write-only invariant)
|
||||
*
|
||||
* Write-only invariant (security):
|
||||
* The stored OpenRouter key must NEVER appear in page source.
|
||||
* #prv_api_key renders with value="" on every page load regardless of
|
||||
* whether a key is stored. This spec asserts the empty value and asserts
|
||||
* that no structurally valid key token (regex /sk-or-v1-[A-Za-z0-9]{20,}/)
|
||||
* appears in the full body HTML. A regex is used instead of a naive
|
||||
* toContain('sk-or-') to avoid a false-positive on the password input's
|
||||
* placeholder="sk-or-…" attribute (the ellipsis is a format hint, not a
|
||||
* token body, and won't match the regex).
|
||||
*
|
||||
* @see peptiderepo/pr-vision includes/core/class-prv-admin-page.php
|
||||
* @see peptiderepo/pr-vision includes/core/class-prv-settings-page.php
|
||||
* @see peptiderepo/pr-vision includes/core/class-prv-key-manager-renderer.php
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const ADMIN = '/wp-admin';
|
||||
|
||||
/**
|
||||
* Detect the "page not registered" condition WordPress returns when a plugin
|
||||
* menu slug is unknown. Returns true when the plugin is absent.
|
||||
*/
|
||||
function isPluginAbsent(body: string, url: string): boolean {
|
||||
return (
|
||||
body.includes('Invalid plugin page') ||
|
||||
body.includes('do not have sufficient permissions') ||
|
||||
body.includes('The page you are looking for') ||
|
||||
( url.includes('/wp-admin/') && ! url.includes('page=pr-vision') )
|
||||
);
|
||||
}
|
||||
|
||||
/** Push a loud skip annotation and call test.skip(). */
|
||||
function skipMissing(): void {
|
||||
test.info().annotations.push({
|
||||
type: 'skip',
|
||||
description:
|
||||
'[pr-vision smoke] PR Vision plugin is not installed or activated ' +
|
||||
'on this environment. Skipping per the merge-early binding.',
|
||||
});
|
||||
test.skip();
|
||||
}
|
||||
|
||||
test.describe('WP Admin - PR Vision dashboard', () => {
|
||||
test('dashboard page loads (HTTP 200, no PHP fatal)', async ({ page }) => {
|
||||
const res = await page.goto(`${ADMIN}/admin.php?page=pr-vision`);
|
||||
expect(res?.status()).toBe(200);
|
||||
|
||||
await expect(page.locator('#wpbody')).toBeVisible();
|
||||
|
||||
const body = (await page.textContent('body')) ?? '';
|
||||
expect(body).not.toContain('Fatal error');
|
||||
|
||||
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
|
||||
|
||||
// Heading from class-prv-admin-page.php render_page():
|
||||
// echo '<h1>' . esc_html__( 'PR Vision - AI Visibility', 'pr-vision' ) . '</h1>';
|
||||
await expect(
|
||||
page.locator('h1').filter({ hasText: 'PR Vision' })
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('WP Admin - PR Vision Settings page (API-key-manager)', () => {
|
||||
test('settings page loads (HTTP 200, no PHP fatal)', async ({ page }) => {
|
||||
const res = await page.goto(`${ADMIN}/admin.php?page=pr-vision-settings`);
|
||||
expect(res?.status()).toBe(200);
|
||||
|
||||
await expect(page.locator('#wpbody')).toBeVisible();
|
||||
|
||||
const body = (await page.textContent('body')) ?? '';
|
||||
expect(body).not.toContain('Fatal error');
|
||||
|
||||
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
|
||||
});
|
||||
|
||||
test('API-key-manager card is present and status indicator visible', async ({ page }) => {
|
||||
await page.goto(`${ADMIN}/admin.php?page=pr-vision-settings`);
|
||||
|
||||
await expect(page.locator('#wpbody')).toBeVisible();
|
||||
const body = (await page.textContent('body')) ?? '';
|
||||
expect(body).not.toContain('Fatal error');
|
||||
|
||||
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
|
||||
|
||||
// ── SKIP-IF-ABSENT guard: Settings page may not have key-card if Settings
|
||||
// subpage (v0.2.0) is not yet deployed on this environment.
|
||||
const keyCard = page.locator('#prv-key-card');
|
||||
const cardPresent = await keyCard.count() > 0;
|
||||
|
||||
if ( ! cardPresent ) {
|
||||
test.info().annotations.push({
|
||||
type: 'skip',
|
||||
description:
|
||||
'[pr-vision smoke] #prv-key-card absent: PR Vision Settings page ' +
|
||||
'(v0.2.0 admin) is not deployed on this environment. Skipping key-manager assertions.',
|
||||
});
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// API-key-manager card container from class-prv-key-manager-renderer.php:
|
||||
// <div class="prv-card" id="prv-key-card">
|
||||
await expect(keyCard).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Key source status indicator: <span ... id="prv-key-source-badge">
|
||||
// Shows one of: "Set via wp-config", "Set via admin", "Not set"
|
||||
await expect(page.locator('#prv-key-source-badge')).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('password input is present and renders EMPTY (write-only invariant)', async ({ page }) => {
|
||||
await page.goto(`${ADMIN}/admin.php?page=pr-vision-settings`);
|
||||
|
||||
await expect(page.locator('#wpbody')).toBeVisible();
|
||||
const body = (await page.textContent('body')) ?? '';
|
||||
expect(body).not.toContain('Fatal error');
|
||||
|
||||
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
|
||||
|
||||
const keyCard = page.locator('#prv-key-card');
|
||||
const cardPresent = await keyCard.count() > 0;
|
||||
if ( ! cardPresent ) {
|
||||
test.info().annotations.push({
|
||||
type: 'skip',
|
||||
description:
|
||||
'[pr-vision smoke] #prv-key-card absent: skipping write-only invariant check.',
|
||||
});
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Password input from class-prv-key-manager-renderer.php render_key_form():
|
||||
// <input type="password" id="prv_api_key" name="prv_api_key" ... value="" ...>
|
||||
// The renderer explicitly passes value="" -- the stored key is NEVER output.
|
||||
const keyInput = page.locator('#prv_api_key');
|
||||
|
||||
// When the key source is SOURCE_CONSTANT the input is disabled but still rendered.
|
||||
// In all cases it must be present in the DOM.
|
||||
await expect(keyInput).toBeAttached({ timeout: 10_000 });
|
||||
|
||||
// Write-only invariant: value attribute must be empty string, not the stored key.
|
||||
const inputValue = await keyInput.getAttribute('value');
|
||||
expect(inputValue ?? '').toBe('');
|
||||
|
||||
// Security check: a *real* OpenRouter key (format "sk-or-v1-<20+ alnum chars>")
|
||||
// must not appear anywhere in the page HTML -- guards against accidental
|
||||
// server-side leakage.
|
||||
//
|
||||
// NOTE: We use a regex rather than the naive toContain('sk-or-') to avoid
|
||||
// a false-positive: the password input carries placeholder="sk-or-…"
|
||||
// (an ellipsis format-hint, not a token body) which contains the literal
|
||||
// string "sk-or-" but is NOT a leaked key. A regex that requires the
|
||||
// "v1-" segment and at least 20 alphanumeric characters only matches a
|
||||
// structurally valid key, so the placeholder never trips it.
|
||||
const html = await page.content();
|
||||
expect(html).not.toMatch(/sk-or-v1-[A-Za-z0-9]{20,}/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('WP Admin - PR Vision Costs page', () => {
|
||||
test('costs page loads without fatal error', async ({ page }) => {
|
||||
const res = await page.goto(`${ADMIN}/admin.php?page=pr-vision-costs`);
|
||||
expect(res?.status()).toBe(200);
|
||||
|
||||
await expect(page.locator('#wpbody')).toBeVisible();
|
||||
const body = (await page.textContent('body')) ?? '';
|
||||
expect(body).not.toContain('Fatal error');
|
||||
|
||||
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
|
||||
|
||||
expect(body).not.toContain('Invalid plugin page');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('WP Admin - PR Vision Call Log page', () => {
|
||||
test('call log page loads without fatal error', async ({ page }) => {
|
||||
const res = await page.goto(`${ADMIN}/admin.php?page=pr-vision-calls`);
|
||||
expect(res?.status()).toBe(200);
|
||||
|
||||
await expect(page.locator('#wpbody')).toBeVisible();
|
||||
const body = (await page.textContent('body')) ?? '';
|
||||
expect(body).not.toContain('Fatal error');
|
||||
|
||||
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
|
||||
|
||||
expect(body).not.toContain('Invalid plugin page');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user