Skip to content
Draft
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
14 changes: 14 additions & 0 deletions .env.e2e.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copy to .env.e2e (gitignored) and fill in real values, then run:
# ./scripts/run_e2e_local.sh
#
# Values may also be 1Password references (e.g. op://vault/item/field); in that
# case run via `op run --env-file=.env.e2e -- ./scripts/run_e2e_local.sh` instead,
# so `op` resolves them before the script sees them.

API_USER=
API_TOKEN=
PA_PASSWORD=
# Full push URL with embedded credentials, e.g.:
# DEMO_REPO_URL=https://x-access-token:<token>@github.com/caktus/dsd-pythonanywhere-testproj.git
DEMO_REPO_URL=
DEMO_REPO_NAME=dsd-pythonanywhere-testproj
73 changes: 73 additions & 0 deletions .github/workflows/e2e_pythonanywhere.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: e2e-pythonanywhere

on:
workflow_dispatch:

concurrency:
group: pa-e2e-deploy
cancel-in-progress: false

env:
DEMO_REPO_NAME: dsd-pythonanywhere-testproj

jobs:
e2e:
runs-on: ubuntu-latest
environment: pa-integration
env:
API_USER: ${{ secrets.PA_API_USER }}
API_TOKEN: ${{ secrets.PA_API_TOKEN }}
PA_PASSWORD: ${{ secrets.PA_PASSWORD }}
# Full clone URL with embedded push credentials, e.g.
# https://x-access-token:<token>@github.com/caktus/dsd-pythonanywhere-testproj.git
DEMO_REPO_URL: ${{ secrets.DEMO_REPO_URL }}
# Resolved by dsd_pythonanywhere.client._get_console_opener(); the plugin's
# tests/ dir is added to PYTHONPATH below so this dotted path resolves both
# in this pytest process and in the `manage.py deploy` child process.
DSD_PYTHONANYWHERE_CONSOLE_OPENER: "e2e_tests.pa_admin:open_console"
steps:
- name: Checkout dsd-pythonanywhere
uses: actions/checkout@v5
with:
path: dsd-pythonanywhere

- name: Checkout django-simple-deploy
uses: actions/checkout@v5
with:
repository: django-simple-deploy/django-simple-deploy
path: django-simple-deploy

- name: Install uv and set the Python version
uses: astral-sh/setup-uv@v7
with:
python-version: "3.14"
enable-cache: true

- name: Install dependencies
run: |
cd django-simple-deploy
uv venv
uv pip install -e ".[dev]"
uv pip install -e ../dsd-pythonanywhere
# playwright is a dev dependency-group, not an extra, so install it directly.
uv pip install playwright
uv run playwright install chromium --with-deps

- name: Pre-test cleanup (webapp + filesystem)
working-directory: django-simple-deploy
env:
PYTHONPATH: ${{ github.workspace }}/dsd-pythonanywhere/tests
run: uv run python -m e2e_tests.pa_admin

- name: Run e2e test
working-directory: django-simple-deploy
env:
PYTHONPATH: ${{ github.workspace }}/dsd-pythonanywhere/tests
run: uv run pytest tests/e2e_tests --plugin dsd_pythonanywhere --automate-all --skip-confirmations -s

- name: Teardown (always runs, even on failure)
if: always()
working-directory: django-simple-deploy
env:
PYTHONPATH: ${{ github.workspace }}/dsd-pythonanywhere/tests
run: uv run python -m e2e_tests.pa_admin
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

*_env/
.envrc
.env.e2e*

*.pyc
__pycache__/
Expand Down
18 changes: 17 additions & 1 deletion dsd_pythonanywhere/client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import importlib
import logging
import os
import re
Expand All @@ -14,6 +15,21 @@
logger = logging.getLogger(__name__)


def _get_console_opener():
"""Resolve the callable used to open a console URL.

Defaults to webbrowser.open. Override with DSD_PYTHONANYWHERE_CONSOLE_OPENER,
a "module.path:function" string, for headless environments like e2e CI.
"""
dotted_path = os.getenv("DSD_PYTHONANYWHERE_CONSOLE_OPENER")
if not dotted_path:
return webbrowser.open

module_path, func_name = dotted_path.split(":", 1)
module = importlib.import_module(module_path)
return getattr(module, func_name)


def log_message(message: str, level: int = logging.INFO, **kwargs) -> None:
"""Helper function to log messages to both logger and plugin_utils output.

Expand Down Expand Up @@ -240,7 +256,7 @@ def wait_for_ready(self) -> None:
# Console not started, open in browser if we haven't already
if not browser_opened:
log_message(" Console not started, opening browser...")
webbrowser.open(self.browser_url)
_get_console_opener()(self.browser_url)
browser_opened = True
# Wait for browser opening to trigger startup
time.sleep(5)
Expand Down
4 changes: 3 additions & 1 deletion dsd_pythonanywhere/platform_deployer.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,9 @@ def _show_success_message(self):
"""
if dsd_config.automate_all:
msg = platform_msgs.success_msg_automate_all(self.deployed_url)
webbrowser.open(self.deployed_url)
# Don't launch a real browser during automated e2e test runs.
if not dsd_config.e2e_testing:
webbrowser.open(self.deployed_url)
else:
msg = platform_msgs.success_msg(log_output=dsd_config.log_output)
plugin_utils.write_output(msg)
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ exclude = [
[dependency-groups]
dev = [
"jupyterlab>=4.4.7",
"playwright>=1.62.0",
"pre-commit>=4.3.0",
"pytest>=8.4.2",
"pytest-mock>=3.15.1",
Expand Down
78 changes: 78 additions & 0 deletions scripts/run_e2e_local.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/bin/bash
set -e

# Run the PythonAnywhere e2e deployment test locally, mirroring
# .github/workflows/e2e_pythonanywhere.yaml.
#
# Usage:
# cd dsd-pythonanywhere && git checkout <pr-branch>
# cp .env.e2e.example .env.e2e # first time only; fill in real values
# ./scripts/run_e2e_local.sh
#
# .env.e2e values can also be 1Password references (e.g. "op://vault/item/field");
# in that case, run via `op run` so they're resolved into the environment before
# this script's own (non-resolving) `source` step runs:
# op run --env-file=.env.e2e -- ./scripts/run_e2e_local.sh
#
# Required env vars (loaded from .env.e2e if present, in the plugin root):
# API_USER PythonAnywhere username
# API_TOKEN PythonAnywhere API token
# PA_PASSWORD PythonAnywhere login password (for the headless console opener)
# DEMO_REPO_URL Push URL for the scratch repo PA will clone, with embedded
# credentials, e.g. https://x-access-token:<token>@github.com/owner/repo.git
# DEMO_REPO_NAME Repo name only, e.g. dsd-pythonanywhere-testproj
#
# Assumes django-simple-deploy is checked out as a sibling directory; override
# with DSD_ROOT=/path/to/django-simple-deploy.
#
# Set CLEAN_VENV=1 to force-recreate $DSD_ROOT/.venv instead of reusing it.

PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DSD_ROOT="${DSD_ROOT:-$PLUGIN_ROOT/../django-simple-deploy}"

# Skip if already provided by `op run` (which resolves op:// refs itself);
# a plain `source` here would otherwise clobber them with the literal op:// string.
if [ -z "${API_USER:-}" ] && [ -f "$PLUGIN_ROOT/.env.e2e" ]; then
echo "Loading env vars from .env.e2e..."
set -a
source "$PLUGIN_ROOT/.env.e2e"
set +a
fi

for var in API_USER API_TOKEN PA_PASSWORD DEMO_REPO_URL DEMO_REPO_NAME; do
if [ -z "${!var:-}" ]; then
echo "Missing required env var: $var (set it, or add it to .env.e2e)" >&2
exit 1
fi
done

if [ ! -d "$DSD_ROOT" ]; then
echo "django-simple-deploy not found at $DSD_ROOT (set DSD_ROOT to override)" >&2
exit 1
fi

export DSD_PYTHONANYWHERE_CONSOLE_OPENER="e2e_tests.pa_admin:open_console"
export PYTHONPATH="$PLUGIN_ROOT/tests"

cd "$DSD_ROOT"

echo "Installing dependencies..."
if [ "${CLEAN_VENV:-}" = "1" ]; then
uv venv --clear
elif [ ! -d ".venv" ]; then
uv venv
fi
uv pip install -e ".[dev]"
uv pip install -e "$PLUGIN_ROOT"
# playwright is a dev dependency-group, not an extra, so install it directly.
uv pip install playwright
uv run playwright install chromium --with-deps

echo "Pre-test cleanup (webapp + filesystem)..."
uv run python -m e2e_tests.pa_admin

echo "Running e2e test..."
uv run pytest tests/e2e_tests --plugin dsd_pythonanywhere --automate-all --skip-confirmations -s

echo "Teardown..."
uv run python -m e2e_tests.pa_admin
73 changes: 73 additions & 0 deletions tests/e2e_tests/pa_admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""PythonAnywhere-specific e2e test helpers: headless console login and account reset.

Both entry points expect PYTHONPATH to include the plugin's tests/ dir, so
this module resolves as the top-level package "e2e_tests.pa_admin" -- see
.github/workflows/e2e_pythonanywhere.yaml and scripts/run_e2e_local.sh.
"""

import logging
import os

from playwright.sync_api import sync_playwright

from . import utils as platform_utils

LOGIN_URL = "https://www.pythonanywhere.com/login/"


def open_console(url: str) -> None:
"""Log in and load a PA console URL headlessly to trigger console startup.

Loading the console URL triggers PA's client-side JS to open a websocket
and spawn the actual bash console process server-side -- something the API
alone cannot do. Resolved via
DSD_PYTHONANYWHERE_CONSOLE_OPENER=e2e_tests.pa_admin:open_console
(see dsd_pythonanywhere.client._get_console_opener).
"""
username = os.environ["API_USER"]
password = os.environ["PA_PASSWORD"]

with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()

page.goto(LOGIN_URL)
page.locator("#id_auth-username").fill(username)
page.locator("#id_auth-password").fill(password)
page.locator("#id_next").click()
page.wait_for_load_state("networkidle")

if "/login/" in page.url:
raise RuntimeError("PythonAnywhere login failed; check PA_PASSWORD secret.")

page.goto(url)
page.wait_for_load_state("networkidle")
page.wait_for_timeout(5000)
browser.close()


def reset_account() -> None:
"""Wipe PythonAnywhere account state so each e2e run starts clean.

setup.sh skips re-cloning/re-creating the venv if they already exist, so a
dirty account from a prior run won't self-clean; call this before (and
after, via `if: always()`) each e2e run. Requires API_USER, API_TOKEN, and
DEMO_REPO_NAME environment variables.
"""
repo_name = os.environ["DEMO_REPO_NAME"]
client = platform_utils.get_client()

if client.webapp_exists():
print("Deleting existing webapp...")
client.webapp.delete()

print(f"Removing ~/{repo_name} and ~/venv...")
client.run_command(f"rm -rf ~/{repo_name} ~/venv")


if __name__ == "__main__":
# dsd_pythonanywhere.client.log_message() only prints via plugin_utils, which
# isn't configured outside a real `manage.py deploy` run; wire up basic
# logging here so console-polling progress is visible.
logging.basicConfig(level=logging.INFO, format="%(message)s")
reset_account()
60 changes: 32 additions & 28 deletions tests/e2e_tests/test_deployment.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import time

import pytest
Expand All @@ -18,6 +19,25 @@ def test_dummy(tmp_project, request):
pass


def _configure_demo_repo_remote(tmp_project):
"""Point tmp_project at the real GitHub repo PythonAnywhere will clone from.

The plugin pushes to `origin` and has PA `git clone` that same URL, so a
real, internet-reachable remote is required -- tmp_project only has a
local git history (see manage_sample_project.py), and it's brand new on
every run, so it's force-pushed onto the remote's `main` branch first.
This makes the plugin's later plain (non-force) `git push origin HEAD` a
fast-forward, without needing branch-deletion permission on the remote.
"""
demo_repo_url = os.environ["DEMO_REPO_URL"]
it_utils.make_sp_call(f"git -C {tmp_project} remote add origin {demo_repo_url}")
result = it_utils.make_sp_call(
f"git -C {tmp_project} push --force origin HEAD:main", capture_output=True
)
if result.returncode != 0:
raise RuntimeError(f"Failed to reset demo repo's main branch: {result.stderr.decode()}")


# Skip this test and enable test_dummy() to speed up testing of setup steps.
# @pytest.mark.skip
def test_deployment(tmp_project, cli_options, request):
Expand All @@ -31,11 +51,14 @@ def test_deployment(tmp_project, cli_options, request):

python_cmd = it_utils.get_python_exe(tmp_project)

# Note: If not using automate_all, take steps here that the end user would take.
# Create a new project on the remote host, if not testing --automate-all.
# if not cli_options.automate_all:
# app_name = platform_utils.create_project()
# request.config.cache.set("app_name", app_name)
# PythonAnywhere's webapp is tied to the account, so only automate_all is
# supported here; a non-automated flow doesn't have a project to create first.
if not cli_options.automate_all:
raise NotImplementedError(
"dsd-pythonanywhere e2e tests only support the --automate-all workflow."
)

_configure_demo_repo_remote(tmp_project)

# Run simple_deploy against the test project.
it_utils.run_simple_deploy(python_cmd, automate_all=cli_options.automate_all)
Expand All @@ -44,37 +67,18 @@ def test_deployment(tmp_project, cli_options, request):
if cli_options.pkg_manager == "pipenv":
it_utils.make_sp_call(f"{python_cmd} -m pipenv lock")

# Note: This is an example of how you can stash information about the deployment,
# which can be used in the teardown phase. You do need to set project_url,
# in order to run functionality tests against the deployed project.
#
# Get the deployed project's URL, and ID so we can destroy it later.
# This also commits configuration changes and pushes the project
# when testing the configuration-only workflow.
# When testing automate_all, cache app_name for teardown work.
# if cli_options.automate_all:
# project_url, app_name = platform_utils.get_project_url_name()
# request.config.cache.set("app_name", app_name)
# else:
# it_utils.commit_configuration_changes()
# project_url = platform_utils.deploy_project(app_name)

# Note: ***** Remove this line, or your test will always report as passed! *****
remote_functionality_passed = True
# Get the deployed project's URL and app name, and cache app_name for teardown.
project_url, app_name = platform_utils.get_project_url_name()
request.config.cache.set("app_name", app_name)

# Remote functionality test often fails if run too quickly after deployment.
print("\nPausing 10s to let deployment finish...")
time.sleep(10)

# Note: Uncomment this section once your deployment is successful, and
# project_url is set in the above section.
#
# Test functionality of both deployed app, and local project.
# We want to make sure the deployment works, but also make sure we haven't
# affected functionality of the local project using the development server.
# remote_functionality_passed = it_utils.check_deployed_app_functionality(
# python_cmd, project_url
# )
remote_functionality_passed = it_utils.check_deployed_app_functionality(python_cmd, project_url)
local_functionality_passed = it_utils.check_local_app_functionality(python_cmd)
log_check_passed = platform_utils.check_log(tmp_project)

Expand Down
Loading